@minimalcorp/tsunagi 0.1.50 → 0.1.52

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 (158) 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/page.js +1 -1
  5. package/.next/standalone/apps/web/.next/server/app/_global-error/page.js.nft.json +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js +2 -2
  14. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +20 -19
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +20 -19
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  20. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +9 -8
  21. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  23. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  25. package/.next/standalone/apps/web/.next/server/app/index.rsc +22 -21
  26. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  27. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +22 -21
  28. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
  29. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +9 -8
  30. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  31. package/.next/standalone/apps/web/.next/server/app/page.js +2 -2
  32. package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
  33. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/apps/web/.next/server/app/settings/page.js +2 -2
  35. package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  36. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  38. package/.next/standalone/apps/web/.next/server/app/settings.rsc +24 -23
  39. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +24 -23
  40. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  41. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +9 -8
  42. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  43. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  44. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  45. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js +2 -2
  46. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  47. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__02o_btn._.js +4 -4
  49. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__03~.ax1._.js +3 -0
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0jj9045._.js → [root-of-the-server]__0h2guje._.js} +2 -2
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0qjafjt._.js +3 -0
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__13yzr3f._.js +4 -0
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/_05_5y28._.js +7 -0
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/_07iexqz._.js +3 -0
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bru-z.._.js +3 -0
  56. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +17 -17
  57. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0hqrmyz._.js +3 -0
  58. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0i8ojdw._.js +14 -0
  59. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0m.hvms._.js +7 -0
  60. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0mljs_s._.js +7 -0
  61. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0o6gl4b._.js +3 -0
  62. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +3 -1
  63. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +12 -12
  64. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0kuu0i_._.js → node_modules_06gyfuv._.js} +1 -1
  65. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_10vm5h.._.js → node_modules_09e0q95._.js} +1 -1
  66. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0-yxz.k._.js → node_modules_09nusj_._.js} +1 -1
  67. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0br2zvr._.js → node_modules_0_876x4._.js} +1 -1
  68. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0rgnv~i._.js → node_modules_0axuayq._.js} +1 -1
  69. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0-5m30v._.js → node_modules_0c8sp_9._.js} +1 -1
  70. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0azs4bs._.js → node_modules_0h_hear._.js} +1 -1
  71. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_01l-p1x._.js → node_modules_0la1a4x._.js} +1 -1
  72. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0-nk4sk._.js → node_modules_0mt.8za._.js} +1 -1
  73. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0ljm1cj._.js → node_modules_0nji2bx._.js} +1 -1
  74. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0zxpl-b._.js → node_modules_0o9t~hh._.js} +1 -1
  75. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0s5.073._.js → node_modules_0s3mkxs._.js} +1 -1
  76. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0wg2e2t._.js → node_modules_0w8sswp._.js} +1 -1
  77. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_053ofle._.js → node_modules_0zv4e8x._.js} +1 -1
  78. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0g-5xh6._.js → node_modules_10v5lct._.js} +1 -1
  79. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0ne-fkd._.js → node_modules_117mm7f._.js} +1 -1
  80. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  81. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  82. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  83. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  84. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  85. package/.next/standalone/apps/web/.next/static/chunks/00b2u96xq7qxl.css +4 -0
  86. package/.next/standalone/apps/web/.next/static/chunks/026168y2bx0d~.js +1 -0
  87. package/.next/standalone/apps/web/.next/static/chunks/03~3ukpvmjafx.js +12 -0
  88. package/.next/standalone/apps/web/.next/static/chunks/{0bhssqb0~3~7n.js → 059w2rbrv6jh4.js} +12 -12
  89. package/.next/standalone/apps/web/.next/static/chunks/08-d-ag2euha9.js +5 -0
  90. package/.next/standalone/apps/web/.next/static/chunks/0d-vappn~2d.d.js +1 -0
  91. package/.next/standalone/apps/web/.next/static/chunks/0f8~7qwyc8ul0.js +1 -0
  92. package/.next/standalone/apps/web/.next/static/chunks/0gpkw.1e6kcpn.js +1 -0
  93. package/.next/standalone/apps/web/.next/static/chunks/0hfsoxrzcikz5.js +7 -0
  94. package/.next/standalone/apps/web/.next/static/chunks/0p3_2uc39mlse.js +5 -0
  95. package/.next/standalone/apps/web/.next/static/chunks/0v3hfoggxwsov.js +64 -0
  96. package/.next/standalone/apps/web/.next/static/chunks/0v6gxm~ldhmma.js +1 -0
  97. package/.next/standalone/apps/web/.next/static/chunks/0wzu~340lh7o~.js +7 -0
  98. package/.next/standalone/apps/web/.next/static/chunks/13y6z-vus9yvr.js +5 -0
  99. package/dist/cli.js +12 -1
  100. package/dist/server/index.js +6 -5
  101. package/dist/server/lib/asr-models.d.ts +7 -0
  102. package/dist/server/lib/asr-models.js +51 -0
  103. package/dist/server/lib/process-port.js +1 -1
  104. package/dist/server/lib/update-check.d.ts +7 -0
  105. package/dist/server/lib/update-check.js +65 -0
  106. package/dist/server/lib/voice-debug-log.d.ts +4 -4
  107. package/dist/server/lib/voice-debug-log.js +1 -1
  108. package/dist/server/lib/whisper-process.d.ts +19 -2
  109. package/dist/server/lib/whisper-process.js +123 -26
  110. package/dist/server/routes/version.d.ts +2 -0
  111. package/dist/server/routes/version.js +7 -0
  112. package/dist/server/routes/whisper.js +37 -56
  113. package/dist/whisper-server/README.md +11 -3
  114. package/dist/whisper-server/download_model.py +8 -3
  115. package/dist/whisper-server/requirements.txt +1 -0
  116. package/dist/whisper-server/run.sh +7 -2
  117. package/dist/whisper-server/server.py +57 -29
  118. package/npm-shrinkwrap.json +2 -2
  119. package/package.json +1 -1
  120. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__08uda78._.js +0 -3
  121. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0kpobap._.js +0 -3
  122. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0m52zsj._.js +0 -3
  123. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0mo_9ei._.js +0 -3
  124. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0stter-._.js +0 -4
  125. package/.next/standalone/apps/web/.next/server/chunks/ssr/_00mhd_n._.js +0 -7
  126. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0i7y5ac._.js +0 -3
  127. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0iscwmf._.js +0 -14
  128. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0ldtf2a._.js +0 -7
  129. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0ydcfna._.js +0 -3
  130. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0z8pikg._.js +0 -7
  131. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0zu_05y._.js +0 -3
  132. package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_0-bra0k._.js +0 -3
  133. package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0qttwc4._.js +0 -3
  134. package/.next/standalone/apps/web/.next/static/chunks/0.ahnhx-ugzbt.js +0 -7
  135. package/.next/standalone/apps/web/.next/static/chunks/01fuewnm93_l5.js +0 -1
  136. package/.next/standalone/apps/web/.next/static/chunks/0ekz145m0f5h8.js +0 -1
  137. package/.next/standalone/apps/web/.next/static/chunks/0n.xodexdkf03.js +0 -1
  138. package/.next/standalone/apps/web/.next/static/chunks/0ohaa3~lhhv1b.js +0 -5
  139. package/.next/standalone/apps/web/.next/static/chunks/0p19bzg3v08hf.js +0 -1
  140. package/.next/standalone/apps/web/.next/static/chunks/0pg.320d4u8z5.js +0 -1
  141. package/.next/standalone/apps/web/.next/static/chunks/0plamn018wq.u.css +0 -4
  142. package/.next/standalone/apps/web/.next/static/chunks/0qbchjvhms-.9.js +0 -5
  143. package/.next/standalone/apps/web/.next/static/chunks/0tcznr_934_ai.js +0 -64
  144. package/.next/standalone/apps/web/.next/static/chunks/0ywpi-uf71k8s.js +0 -1
  145. package/.next/standalone/apps/web/.next/static/chunks/12av8cpq.8slk.js +0 -5
  146. package/.next/standalone/apps/web/.next/static/chunks/12n4j5ugu5jbn.js +0 -12
  147. package/.next/standalone/apps/web/.next/static/chunks/16ehoz1y-qf39.js +0 -5
  148. package/dist/llm-server/README.md +0 -27
  149. package/dist/llm-server/download_model.py +0 -14
  150. package/dist/llm-server/requirements.txt +0 -2
  151. package/dist/llm-server/run.sh +0 -59
  152. package/dist/server/lib/llm-process.d.ts +0 -29
  153. package/dist/server/lib/llm-process.js +0 -261
  154. package/dist/server/routes/llm.d.ts +0 -2
  155. package/dist/server/routes/llm.js +0 -23
  156. /package/.next/standalone/apps/web/.next/static/{w7do3hyV84oyognhRCQuA → et2hOuZAP9TEcN1mn6uIG}/_buildManifest.js +0 -0
  157. /package/.next/standalone/apps/web/.next/static/{w7do3hyV84oyognhRCQuA → et2hOuZAP9TEcN1mn6uIG}/_clientMiddlewareManifest.js +0 -0
  158. /package/.next/standalone/apps/web/.next/static/{w7do3hyV84oyognhRCQuA → et2hOuZAP9TEcN1mn6uIG}/_ssgManifest.js +0 -0
@@ -9,7 +9,7 @@ const LOG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.
9
9
  const LOG_FILE = path.join(LOG_DIR, 'voice-input-debug.log');
10
10
  // デバッグ用途のため肥大化を防ぐ簡易ローテーション(直近件数のみ保持)。
11
11
  const MAX_ENTRIES = 200;
12
- // Whisper生テキストとLLM整形結果を1リクエスト1行(JSON Lines)で追記する。
12
+ // モデル・処理時間・プロンプト・文字起こし結果を1リクエスト1行(JSON Lines)で追記する。
13
13
  // プロンプト改善のデバッグ専用ログであり、書き込み失敗はリクエスト自体を
14
14
  // 失敗させるべきではないため例外を握りつぶす。
15
15
  export function appendVoiceDebugLog(entry) {
@@ -1,3 +1,4 @@
1
+ import type { AsrModelList } from '@minimalcorp/tsunagi-shared';
1
2
  export declare function findWhisperServerDir(): string | null;
2
3
  export type WhisperServerStep = 'not_running' | 'installing_deps' | 'downloading_model' | 'starting_server' | 'running' | 'running_external' | 'error';
3
4
  export interface DownloadProgress {
@@ -8,16 +9,32 @@ export interface DownloadProgress {
8
9
  export interface WhisperServerInfo {
9
10
  step: WhisperServerStep;
10
11
  serverDir: string | null;
12
+ /** 起動中のサーバーが読み込んでいる(セットアップ中は読み込もうとしている)モデル */
13
+ modelId: string | null;
11
14
  downloadProgress?: DownloadProgress;
12
15
  error?: string;
13
16
  }
14
17
  export declare function getWhisperServerStatus(): Promise<WhisperServerInfo>;
15
- export declare function startWhisperServer(): {
18
+ export declare function startWhisperServer(): Promise<{
16
19
  started: boolean;
17
20
  error?: string;
18
- };
21
+ }>;
19
22
  export declare function stopWhisperServer(): Promise<{
20
23
  stopped: boolean;
21
24
  error?: string;
22
25
  }>;
23
26
  export declare function stopWhisperServerOnExit(): void;
27
+ export declare function listAsrModels(): Promise<AsrModelList>;
28
+ export declare function selectAsrModel(modelId: string): Promise<{
29
+ ok: true;
30
+ restarted: boolean;
31
+ } | {
32
+ ok: false;
33
+ error: string;
34
+ }>;
35
+ export declare function deleteAsrModel(modelId: string): Promise<{
36
+ ok: true;
37
+ } | {
38
+ ok: false;
39
+ error: string;
40
+ }>;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function versionRoutes(fastify: FastifyInstance): Promise<void>;
@@ -0,0 +1,7 @@
1
+ import { getUpdateStatus } from '../lib/update-check.js';
2
+ export async function versionRoutes(fastify) {
3
+ // GET /version - 実行中のバージョンと npm の最新バージョン
4
+ fastify.get('/version', async (_request, reply) => {
5
+ return reply.status(200).send({ data: getUpdateStatus() });
6
+ });
7
+ }
@@ -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,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
- // 発話中の途中経過表示用のリクエスト。確定前の暫定結果なので、LLM整形と
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
- ローカル音声入力用サーバー。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
  ## 要件
@@ -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` 経由で呼び出す。