@minimalcorp/tsunagi 0.1.49 → 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.
Files changed (112) hide show
  1. package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
  2. package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
  3. package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +2 -2
  14. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  20. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  21. package/.next/standalone/apps/web/.next/server/app/index.rsc +3 -3
  22. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  23. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +3 -3
  24. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
  25. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +2 -2
  26. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  27. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  28. package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  29. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  30. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  31. package/.next/standalone/apps/web/.next/server/app/settings.rsc +3 -3
  32. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  33. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  34. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  35. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  36. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  37. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  38. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  39. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0m9m3y5._.js +3 -0
  41. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0mo_9ei._.js +1 -1
  42. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__11y.qj~._.js +3 -0
  43. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bru-z.._.js +3 -0
  44. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0ejfw_6._.js +3 -0
  45. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +1 -1
  46. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +3 -1
  47. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +12 -12
  48. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_102_61p._.js → node_modules_02g1ce1._.js} +1 -1
  49. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0_j5e9s._.js → node_modules_09jfkt3._.js} +1 -1
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_1132ajp._.js → node_modules_0c2_xkv._.js} +1 -1
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_05v_qny._.js → node_modules_0j9ao3l._.js} +1 -1
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0mn5p-q._.js → node_modules_0k-a_k6._.js} +1 -1
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0j-2rnz._.js → node_modules_0ne_55f._.js} +1 -1
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0esrd80._.js → node_modules_0o9r9wn._.js} +1 -1
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0bwy~wk._.js → node_modules_0wrve0t._.js} +1 -1
  56. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  57. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  58. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  59. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  60. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  61. package/.next/standalone/apps/web/.next/static/chunks/008lct.76g--1.js +1 -0
  62. package/.next/standalone/apps/web/.next/static/chunks/0gpkw.1e6kcpn.js +1 -0
  63. package/.next/standalone/apps/web/.next/static/chunks/0k4w860cwzuyy.css +4 -0
  64. package/.next/standalone/apps/web/.next/static/chunks/0makl-d3f0_qh.js +2878 -0
  65. package/.next/standalone/apps/web/.next/static/chunks/{0hcpu00flu9x6.js → 0tcznr_934_ai.js} +1 -1
  66. package/.next/standalone/apps/web/.next/static/chunks/0v6gxm~ldhmma.js +1 -0
  67. package/.next/standalone/apps/web/.next/static/chunks/0wv-.6vqubfpy.js +23 -0
  68. package/.next/standalone/apps/web/.next/static/chunks/0xy-wfkab7_5n.js +7 -0
  69. package/.next/standalone/apps/web/package.json +3 -0
  70. package/.next/standalone/apps/web/public/vad/ort-wasm-simd-threaded.mjs +59 -0
  71. package/.next/standalone/apps/web/public/vad/ort-wasm-simd-threaded.wasm +0 -0
  72. package/.next/standalone/apps/web/public/vad/silero_vad_v5.onnx +0 -0
  73. package/.next/standalone/apps/web/public/vad/vad.worklet.bundle.min.js +1 -0
  74. package/dist/cli.js +0 -1
  75. package/dist/server/index.js +0 -5
  76. package/dist/server/lib/asr-models.d.ts +7 -0
  77. package/dist/server/lib/asr-models.js +51 -0
  78. package/dist/server/lib/process-port.js +1 -1
  79. package/dist/server/lib/voice-debug-log.d.ts +4 -4
  80. package/dist/server/lib/voice-debug-log.js +1 -1
  81. package/dist/server/lib/whisper-process.d.ts +19 -2
  82. package/dist/server/lib/whisper-process.js +123 -26
  83. package/dist/server/routes/whisper.js +39 -51
  84. package/dist/whisper-server/README.md +12 -4
  85. package/dist/whisper-server/download_model.py +8 -3
  86. package/dist/whisper-server/requirements.txt +1 -0
  87. package/dist/whisper-server/run.sh +7 -2
  88. package/dist/whisper-server/server.py +90 -17
  89. package/npm-shrinkwrap.json +2 -2
  90. package/package.json +1 -1
  91. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0m52zsj._.js +0 -3
  92. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0_2b4wf._.js +0 -3
  93. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0s18_3n._.js +0 -3
  94. package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_0.lo~su._.js +0 -3
  95. package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0qttwc4._.js +0 -3
  96. package/.next/standalone/apps/web/.next/static/chunks/04g3fzo25y9_g.js +0 -23
  97. package/.next/standalone/apps/web/.next/static/chunks/06sk6d3k059.n.js +0 -1
  98. package/.next/standalone/apps/web/.next/static/chunks/0imnlpmbrdgiv.js +0 -5
  99. package/.next/standalone/apps/web/.next/static/chunks/0n.xodexdkf03.js +0 -1
  100. package/.next/standalone/apps/web/.next/static/chunks/0u-820sh7-.-1.js +0 -1
  101. package/.next/standalone/apps/web/.next/static/chunks/13~5_8644~sbn.css +0 -4
  102. package/dist/llm-server/README.md +0 -27
  103. package/dist/llm-server/download_model.py +0 -14
  104. package/dist/llm-server/requirements.txt +0 -2
  105. package/dist/llm-server/run.sh +0 -59
  106. package/dist/server/lib/llm-process.d.ts +0 -29
  107. package/dist/server/lib/llm-process.js +0 -261
  108. package/dist/server/routes/llm.d.ts +0 -2
  109. package/dist/server/routes/llm.js +0 -23
  110. /package/.next/standalone/apps/web/.next/static/{oezQccA9cBsGwIkuMGqIY → 1z7N6ZhcQwVJOkcsbIi4M}/_buildManifest.js +0 -0
  111. /package/.next/standalone/apps/web/.next/static/{oezQccA9cBsGwIkuMGqIY → 1z7N6ZhcQwVJOkcsbIi4M}/_clientMiddlewareManifest.js +0 -0
  112. /package/.next/standalone/apps/web/.next/static/{oezQccA9cBsGwIkuMGqIY → 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初期化のばらつきを吸収するため60秒に設定。
18
+ // 遅いマシンでのPython起動/import/Metal初期化に加え、Qwen3-ASRは起動時にモデル(最大約4GB)を
19
+ // メモリへ読み込むため、そのばらつきを吸収できるよう120秒に設定。
21
20
  // プロセスが起動途中でクラッシュした場合はこのタイムアウトを待たず即座に検知する。
22
- const STARTUP_TIMEOUT_MS = 60_000;
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
- function isModelReady() {
40
- const snapshotsDir = path.join(MODEL_CACHE_DIR, 'snapshots');
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
- return response.ok;
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 false;
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 { step: currentStep, serverDir, downloadProgress, error: lastError };
80
+ return {
81
+ step: currentStep,
82
+ serverDir,
83
+ modelId: targetModel?.id ?? null,
84
+ downloadProgress,
85
+ error: lastError,
86
+ };
69
87
  }
70
- const healthy = await checkHealth();
71
- if (healthy) {
72
- return { step: managedProcess ? 'running' : 'running_external', serverDir };
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 { step: lastError ? 'error' : 'not_running', serverDir, error: lastError };
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(MODEL_CACHE_DIR, 'blobs');
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 = EXPECTED_MODEL_BYTES - downloaded;
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: EXPECTED_MODEL_BYTES,
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 = { ...process.env, HF_HOME: HF_CACHE_DIR, HF_HUB_DISABLE_XET: '1' };
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: EXPECTED_MODEL_BYTES, etaSeconds: null };
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
- export function startWhisperServer() {
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
- setupPromise = runSetupAndStart(dir)
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
- // ユーザーが依存関係(pipパッケージ)をセットアップ済みのローカル常駐サーバー
5
- // (apps/whisper-server)を指す。依存関係のインストール自体はtsunagi側では行わず、
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,21 +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
- // ローカルLLMによる整形を使うかどうかはクライアント(Settingsの有効/無効フラグ)から渡される。
45
- const useLlmField = file.fields.useLlm;
46
- const useLlm = useLlmField?.value === 'true';
47
- // LLM整形用システムプロンプトもSettings画面で編集・保存でき、指定があれば
48
- // 既定のプロンプトの代わりに使う(空文字列の場合は既定のプロンプトを使う)。
49
- const systemPromptField = file.fields.systemPrompt;
50
- const systemPrompt = typeof systemPromptField?.value === 'string' && systemPromptField.value.trim()
51
- ? systemPromptField.value
52
- : DEFAULT_TRANSCRIPTION_CORRECTION_SYSTEM_PROMPT;
61
+ // 発話中の途中経過表示用のリクエスト。確定前の暫定結果なので、
62
+ // デバッグログはスキップする。
63
+ const interimField = file.fields.interim;
64
+ const interim = interimField?.value === 'true';
53
65
  const buffer = await file.toBuffer();
54
66
  const body = new FormData();
55
67
  body.append('file', new Blob([buffer]), file.filename);
56
68
  if (prompt)
57
69
  body.append('prompt', prompt);
58
70
  let whisperText;
71
+ let model;
72
+ const startedAt = Date.now();
59
73
  try {
60
74
  const response = await fetch(`${WHISPER_SERVER_URL}/transcribe`, {
61
75
  method: 'POST',
@@ -66,6 +80,7 @@ export async function whisperRoutes(fastify) {
66
80
  }
67
81
  const result = (await response.json());
68
82
  whisperText = result.text;
83
+ model = result.model;
69
84
  }
70
85
  catch (error) {
71
86
  fastify.log.error(error, 'Whisper transcription error');
@@ -75,41 +90,14 @@ export async function whisperRoutes(fastify) {
75
90
  : 'Transcription failed. Is apps/whisper-server running?',
76
91
  });
77
92
  }
78
- if (!useLlm || !whisperText) {
79
- appendVoiceDebugLog({ whisperPrompt: prompt, whisperText, useLlm });
80
- return reply.status(200).send({ text: whisperText });
81
- }
82
- // LLMによる整形が有効な場合のみ追加で問い合わせる。文字起こし自体は既に
83
- // 成功しているため、ここで失敗しても文字起こし結果はそのまま返す
84
- // (ユーザーの発話内容を無駄にしないことを、整形の成否より優先する)。
85
- try {
86
- const corrected = await generateLlmCompletion([
87
- { role: 'system', content: systemPrompt },
88
- { role: 'user', content: whisperText },
89
- ], Math.min(4096, Math.max(512, whisperText.length * 4)));
90
- const correctedText = corrected.trim();
91
- appendVoiceDebugLog({
92
- whisperPrompt: prompt,
93
- whisperText,
94
- useLlm,
95
- llmSystemPrompt: systemPrompt,
96
- correctedText,
97
- });
98
- return reply.status(200).send({ text: correctedText });
99
- }
100
- catch (error) {
101
- fastify.log.error(error, 'LLM correction failed, falling back to raw transcription');
93
+ if (!interim) {
102
94
  appendVoiceDebugLog({
95
+ model,
96
+ elapsedMs: Date.now() - startedAt,
103
97
  whisperPrompt: prompt,
104
98
  whisperText,
105
- useLlm,
106
- llmSystemPrompt: systemPrompt,
107
- llmError: error instanceof Error ? error.message : String(error),
108
- });
109
- return reply.status(200).send({
110
- text: whisperText,
111
- warning: 'ローカルLLMによる整形に失敗したため、文字起こし結果をそのまま使用しました',
112
99
  });
113
100
  }
101
+ return reply.status(200).send({ text: whisperText });
114
102
  });
115
103
  }
@@ -1,6 +1,14 @@
1
1
  # whisper-server
2
2
 
3
- ローカル音声入力用サーバー。mlx-whisper (Apple Silicon GPU) で文字起こしする。
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
  ## 要件
@@ -8,7 +16,7 @@ tsunagi本体はこのサーバーにHTTPでプロキシするだけで、セッ
8
16
  - macOS (Apple Silicon: M1/M2/M3/M4)
9
17
  - Python 3.9+ (Xcode Command Line Tools または Homebrew 経由で入手可能)
10
18
 
11
- ffmpeg等の外部バイナリは不要(音声はブラウザ側で16kHzモノラルWAVに変換して送られる)。
19
+ ffmpeg等の外部バイナリは不要(音声はブラウザ側のVADで発話区間だけ切り出し、16kHzモノラルWAVで送られる)。
12
20
 
13
21
  ## セットアップ・起動
14
22
 
@@ -17,7 +25,7 @@ cd apps/whisper-server
17
25
  ./run.sh
18
26
  ```
19
27
 
20
- 初回は`.venv`を作成し`requirements.txt`の依存関係(mlx-whisper, fastapi, uvicorn等)をインストールしてから起動する。
21
- `http://127.0.0.1:8765`で待受。初回起動時にWhisperモデル(mlx-community/whisper-large-v3-turbo, 約1.5GB)を自動ダウンロードする。
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
- """Whisperモデルの重みを事前ダウンロードするだけのスクリプト。
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
- MODEL_REPO = "mlx-community/whisper-large-v3-turbo"
16
+ DEFAULT_MODEL_REPO = "mlx-community/whisper-large-v3-turbo"
12
17
 
13
18
  if __name__ == "__main__":
14
- snapshot_download(repo_id=MODEL_REPO)
19
+ snapshot_download(repo_id=sys.argv[1] if len(sys.argv) > 1 else DEFAULT_MODEL_REPO)
@@ -1,4 +1,5 @@
1
1
  mlx-whisper==0.4.3
2
+ mlx-qwen3-asr==0.4.4
2
3
  mlx==0.32.0
3
4
  fastapi
4
5
  uvicorn[standard]
@@ -31,7 +31,7 @@ find_python() {
31
31
  }
32
32
 
33
33
  if ! PYTHON_BIN="$(find_python)"; then
34
- echo "音声入力(Whisper)サーバーの起動に失敗: Python ${MIN_PYTHON_VERSION}以上が見つかりません。" >&2
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,35 +1,78 @@
1
- """ローカルWhisper文字起こしサーバー (mlx-whisper, Apple Silicon GPU)。
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でプロキシされる。
5
9
 
6
- 音声はブラウザのMediaRecorderが生成する元のwebm/opusをそのまま受け取る
7
- (クライアント側でのWAV再エンコードは行わない。Opus圧縮は録音時点で既に
8
- 発生しているため、無圧縮WAVへ変換し直しても音質は改善せず転送量が増えるだけ)。
9
- デコードにはPyAV(`av`)を使う。ffmpegの内部コーデックをホイールに同梱しており、
10
+ 音声はブラウザ側のVAD(Silero VAD)で発話区間だけを切り出した16kHzモノラルWAVを受け取る。
11
+ デコードにはPyAV(`av`)を使う(WAV以外の形式もそのままデコードできる)。ffmpegの内部コーデックをホイールに同梱しており、
10
12
  システムにffmpegバイナリをインストールする必要はない。
11
13
  """
12
14
 
13
15
  import io
16
+ import os
17
+ import re
18
+ import threading
14
19
  from typing import Optional
15
20
 
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
- MODEL = "mlx-community/whisper-large-v3-turbo"
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(無音である確率)がこの値を超えるセグメントは出力から除外する。
25
41
  NO_SPEECH_THRESHOLD = 0.6
42
+ # ただしno_speech_probは30秒の窓ごとに1回しか計算されず、窓内の全セグメントに同じ値が
43
+ # コピーされる。そのため「発話+末尾の無音」を含む窓では、無音部分から生成された定型文も
44
+ # 発話ありと判定されて素通りする。これを補うため、発話より後ろ(または前)の無音区間に
45
+ # 現れた異常セグメントを読み飛ばすmlx_whisper組込みの判定(要word_timestamps)を有効にする。
46
+ HALLUCINATION_SILENCE_THRESHOLD = 2.0
47
+ # それでも残った場合の最終防衛線。YouTube字幕由来の典型的なハルシネーションで、
48
+ # セグメント全体が(句読点・空白を除いて)これらと完全一致する場合のみ除外する。
49
+ # 部分一致にしないのは、ユーザーが実際にこれらの語を含む文を話すケースを壊さないため。
50
+ HALLUCINATION_PHRASES = {
51
+ "ご視聴ありがとうございました",
52
+ "ご視聴ありがとうございます",
53
+ "最後までご視聴いただきありがとうございました",
54
+ "最後までご視聴ありがとうございました",
55
+ "チャンネル登録お願いします",
56
+ "チャンネル登録をお願いします",
57
+ "チャンネル登録よろしくお願いします",
58
+ "次回もお楽しみに",
59
+ "次回予告",
60
+ "おやすみなさい",
61
+ }
62
+ _NORMALIZE_RE = re.compile(r"[\s、。,.,.!!??・…「」『』()()\[\]〜~♪]+")
63
+
64
+ # MLXの推論は同時実行を想定していないため直列化する。エンドポイント自体は
65
+ # 同期関数にしてスレッドプールで実行させ、推論中も/healthが応答できるようにする
66
+ # (途中経過の表示で文字起こしリクエストが連続しても、起動状態のポーリングが
67
+ # タイムアウトしてUIが「未起動」扱いになるのを防ぐ)。
68
+ _transcribe_lock = threading.Lock()
26
69
 
27
70
  app = FastAPI()
28
71
 
29
72
 
30
73
  @app.get("/health")
31
74
  async def health() -> dict:
32
- return {"status": "ok", "model": MODEL}
75
+ return {"status": "ok", "engine": ENGINE, "model": MODEL}
33
76
 
34
77
 
35
78
  def decode_to_16k_mono(data: bytes) -> np.ndarray:
@@ -49,26 +92,56 @@ def decode_to_16k_mono(data: bytes) -> np.ndarray:
49
92
  return samples
50
93
 
51
94
 
52
- @app.post("/transcribe")
53
- async def transcribe(file: UploadFile = File(...), prompt: Optional[str] = Form(None)) -> dict:
54
- data = await file.read()
55
- audio = decode_to_16k_mono(data)
95
+ def is_hallucination_phrase(text: str) -> bool:
96
+ return _NORMALIZE_RE.sub("", text) in HALLUCINATION_PHRASES
97
+
98
+
99
+ def transcribe_whisper(audio: np.ndarray, prompt: Optional[str]) -> str:
56
100
  # initial_promptは文字起こしのスタイル(表記ゆれ・句読点・固有名詞など)を
57
101
  # 誘導するヒントで、tsunagiのSettingsからユーザーが自由に設定できる。
58
102
  result = mlx_whisper.transcribe(
59
- audio, path_or_hf_repo=MODEL, language="ja", initial_prompt=prompt or None
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,
60
112
  )
61
113
 
62
114
  # result["text"]はno_speech_prob等のフィルタを経ずに全セグメントを結合した
63
115
  # ものなので使わず、セグメント単位でno_speech_probを見て自前で組み立て直す。
64
116
  segments = result.get("segments")
65
117
  if segments:
66
- text = "".join(
118
+ return "".join(
67
119
  seg["text"]
68
120
  for seg in segments
69
121
  if seg.get("no_speech_prob", 0.0) <= NO_SPEECH_THRESHOLD
122
+ and not is_hallucination_phrase(seg["text"])
70
123
  )
71
- else:
72
- text = result.get("text", "")
124
+ text = result.get("text", "")
125
+ return "" if is_hallucination_phrase(text) else text
126
+
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
73
134
 
74
- return {"text": text.strip()}
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}
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@minimalcorp/tsunagi",
3
- "version": "0.1.49",
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.49",
9
+ "version": "0.1.51",
10
10
  "license": "SEE LICENSE IN LICENSE",
11
11
  "os": [
12
12
  "darwin",