@minimalcorp/tsunagi 0.4.0 → 0.4.2

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 (116) 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.js.nft.json +1 -1
  28. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  29. package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  30. package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  31. package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
  32. package/.next/standalone/apps/web/.next/server/app/settings.rsc +3 -3
  33. package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  34. package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  35. package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  36. package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  37. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  38. package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  39. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  40. package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0qjafjt._.js +1 -1
  42. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_13w5ytx._.js → _01~tp-i._.js} +2 -2
  43. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_0pw3_r_._.js → _03lm.~7._.js} +3 -3
  44. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_0rb6-mo._.js → _09br4yx._.js} +3 -3
  45. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0h-u~an._.js +7 -0
  46. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_139_l.u._.js → _0kkt7a_._.js} +2 -2
  47. package/.next/standalone/apps/web/.next/server/chunks/ssr/_13ne7ej._.js +3 -0
  48. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +1 -3
  49. package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +2 -2
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0nzbb9~._.js → node_modules_0ac2-~z._.js} +1 -1
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0e8uni7._.js → node_modules_0gglkx9._.js} +1 -1
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0zi-gfw._.js → node_modules_0i5v48e._.js} +1 -1
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0xzmx-x._.js → node_modules_0k3x6_w._.js} +1 -1
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0~aiu12._.js → node_modules_0rnsluy._.js} +1 -1
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0vj~e_v._.js → node_modules_0t-jssg._.js} +1 -1
  56. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0r6t2tu._.js → node_modules_0ut2-yw._.js} +1 -1
  57. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0925te2._.js → node_modules_0w6fm3f._.js} +2 -2
  58. package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_03-rvdo._.js → node_modules_0zi0w9_._.js} +1 -1
  59. package/.next/standalone/apps/web/.next/server/chunks/ssr/{_0w2dm3r._.js → node_modules_lucide-react_dist_esm_icons_0zerlct._.js} +2 -2
  60. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  61. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  62. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  63. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  64. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  65. package/.next/standalone/apps/web/.next/static/chunks/{0l83q95s~tih4.js → 08s3o7outb~0z.js} +1 -1
  66. package/.next/standalone/apps/web/.next/static/chunks/0a2u28f44p7t..js +1 -0
  67. package/.next/standalone/apps/web/.next/static/chunks/{1624992q-gpj2.js → 0ae7zmpe8063z.js} +2 -2
  68. package/.next/standalone/apps/web/.next/static/chunks/0dgu~u-1e5b7-.js +5 -0
  69. package/.next/standalone/apps/web/.next/static/chunks/{0whl1y16cvn5q.js → 0e8u7rqrqfiiy.js} +1 -1
  70. package/.next/standalone/apps/web/.next/static/chunks/0n72w4sxrwoj4.js +5 -0
  71. package/.next/standalone/apps/web/.next/static/chunks/0o1bh_qwdvmid.css +4 -0
  72. package/.next/standalone/apps/web/.next/static/chunks/{17~93fal1fw_d.js → 0uugzgqr-ug3_.js} +1 -1
  73. package/.next/standalone/apps/web/.next/static/chunks/0ysgl4vmer~v5.js +6 -0
  74. package/.next/standalone/apps/web/.next/static/chunks/{0smdykrt42e.5.js → 11u7x4ic4~mp3.js} +2 -2
  75. package/.next/standalone/apps/web/.next/static/chunks/13_9zaka0bif_.js +1 -0
  76. package/.next/standalone/apps/web/package.json +0 -3
  77. package/dist/app.js +0 -1
  78. package/dist/server/index.js +0 -5
  79. package/dist/server/lib/agent-profiles.d.ts +1 -0
  80. package/dist/server/lib/agent-profiles.js +5 -2
  81. package/npm-shrinkwrap.json +27 -14
  82. package/package.json +1 -1
  83. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__11y.qj~._.js +0 -3
  84. package/.next/standalone/apps/web/.next/server/chunks/ssr/_03vftq7._.js +0 -7
  85. package/.next/standalone/apps/web/.next/server/chunks/ssr/_05n88to._.js +0 -3
  86. package/.next/standalone/apps/web/.next/static/chunks/0.gcmzq82~36i.js +0 -1
  87. package/.next/standalone/apps/web/.next/static/chunks/06gfh91..uydi.js +0 -5
  88. package/.next/standalone/apps/web/.next/static/chunks/09b_3duxnyfto.js +0 -7
  89. package/.next/standalone/apps/web/.next/static/chunks/0i9h-39w91ybd.js +0 -6
  90. package/.next/standalone/apps/web/.next/static/chunks/0makl-d3f0_qh.js +0 -2878
  91. package/.next/standalone/apps/web/.next/static/chunks/0pm0knaijueoc.css +0 -4
  92. package/.next/standalone/apps/web/.next/static/chunks/14zh2vf8ljbxo.js +0 -1
  93. package/.next/standalone/apps/web/public/vad/ort-wasm-simd-threaded.mjs +0 -59
  94. package/.next/standalone/apps/web/public/vad/ort-wasm-simd-threaded.wasm +0 -0
  95. package/.next/standalone/apps/web/public/vad/silero_vad_v5.onnx +0 -0
  96. package/.next/standalone/apps/web/public/vad/vad.worklet.bundle.min.js +0 -1
  97. package/dist/server/lib/asr-models.d.ts +0 -7
  98. package/dist/server/lib/asr-models.js +0 -51
  99. package/dist/server/lib/process-port.d.ts +0 -1
  100. package/dist/server/lib/process-port.js +0 -28
  101. package/dist/server/lib/python-venv.d.ts +0 -4
  102. package/dist/server/lib/python-venv.js +0 -66
  103. package/dist/server/lib/voice-debug-log.d.ts +0 -9
  104. package/dist/server/lib/voice-debug-log.js +0 -28
  105. package/dist/server/lib/whisper-process.d.ts +0 -40
  106. package/dist/server/lib/whisper-process.js +0 -333
  107. package/dist/server/routes/whisper.d.ts +0 -2
  108. package/dist/server/routes/whisper.js +0 -103
  109. package/dist/whisper-server/README.md +0 -31
  110. package/dist/whisper-server/download_model.py +0 -19
  111. package/dist/whisper-server/requirements.txt +0 -8
  112. package/dist/whisper-server/run.sh +0 -62
  113. package/dist/whisper-server/server.py +0 -147
  114. /package/.next/standalone/apps/web/.next/static/{ZcOuTrikuO_ZLaERk7FsW → vdAs-hr87-L7y2bUSdX3i}/_buildManifest.js +0 -0
  115. /package/.next/standalone/apps/web/.next/static/{ZcOuTrikuO_ZLaERk7FsW → vdAs-hr87-L7y2bUSdX3i}/_clientMiddlewareManifest.js +0 -0
  116. /package/.next/standalone/apps/web/.next/static/{ZcOuTrikuO_ZLaERk7FsW → vdAs-hr87-L7y2bUSdX3i}/_ssgManifest.js +0 -0
@@ -1,40 +0,0 @@
1
- import type { AsrModelList } from '@minimalcorp/tsunagi-shared';
2
- export declare function findWhisperServerDir(): string | null;
3
- export type WhisperServerStep = 'not_running' | 'installing_deps' | 'downloading_model' | 'starting_server' | 'running' | 'running_external' | 'error';
4
- export interface DownloadProgress {
5
- downloadedBytes: number;
6
- totalBytes: number;
7
- etaSeconds: number | null;
8
- }
9
- export interface WhisperServerInfo {
10
- step: WhisperServerStep;
11
- serverDir: string | null;
12
- /** 起動中のサーバーが読み込んでいる(セットアップ中は読み込もうとしている)モデル */
13
- modelId: string | null;
14
- downloadProgress?: DownloadProgress;
15
- error?: string;
16
- }
17
- export declare function getWhisperServerStatus(): Promise<WhisperServerInfo>;
18
- export declare function startWhisperServer(): Promise<{
19
- started: boolean;
20
- error?: string;
21
- }>;
22
- export declare function stopWhisperServer(): Promise<{
23
- stopped: boolean;
24
- error?: string;
25
- }>;
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
- }>;
@@ -1,333 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import * as fs from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { ASR_MODELS, findAsrModel, findAsrModelByRepo, getVoiceInputSettings, saveVoiceInputSettings, } from './asr-models.js';
7
- import { killProcessOnPort } from './process-port.js';
8
- import { findPython, isVenvUpToDate, PYTHON_NOT_FOUND_MESSAGE, writeVenvMarker, } from './python-venv.js';
9
- const WHISPER_SERVER_URL = process.env.TSUNAGI_WHISPER_SERVER_URL || 'http://127.0.0.1:8765';
10
- // run.shが待受けるポート固定値(host.docker.internal経由URLと違い、停止処理は
11
- // 必ずこのNodeプロセスと同じホスト上のポートを対象にする必要があるため分けて持つ)。
12
- const WHISPER_PORT = 8765;
13
- // venv・モデルキャッシュとも ~/.tsunagi/whisper 配下にまとめる(run.shと同じ場所)。
14
- const TSUNAGI_WHISPER_DIR = path.join(os.homedir(), '.tsunagi', 'whisper');
15
- const VENV_DIR = path.join(TSUNAGI_WHISPER_DIR, 'venv');
16
- const HF_CACHE_DIR = path.join(TSUNAGI_WHISPER_DIR, 'cache');
17
- // starting_serverフェーズ(spawn後にhealthyになるまで待つ時間)の上限。
18
- // 遅いマシンでのPython起動/import/Metal初期化に加え、Qwen3-ASRは起動時にモデル(最大約4GB)を
19
- // メモリへ読み込むため、そのばらつきを吸収できるよう120秒に設定。
20
- // プロセスが起動途中でクラッシュした場合はこのタイムアウトを待たず即座に検知する。
21
- const STARTUP_TIMEOUT_MS = 120_000;
22
- // モデル切り替え時、停止したサーバーのポートが空くまで待つ時間の上限。
23
- const SHUTDOWN_TIMEOUT_MS = 15_000;
24
- // クラッシュ時にエラーメッセージへ含めるstderrの上限文字数。
25
- const STDERR_TAIL_MAX_CHARS = 4000;
26
- // このファイルの位置から whisper-server を探す:
27
- // npm配布物: apps/cli/dist/server/lib → 2階層上 → apps/cli/dist/whisper-server
28
- // dev/build: apps/server/{src,dist}/lib → 3階層上 → apps/whisper-server
29
- export function findWhisperServerDir() {
30
- const here = path.dirname(fileURLToPath(import.meta.url));
31
- const candidates = [
32
- path.join(here, '..', '..', 'whisper-server'),
33
- path.join(here, '..', '..', '..', 'whisper-server'),
34
- ];
35
- return candidates.find((c) => fs.existsSync(path.join(c, 'run.sh'))) ?? null;
36
- }
37
- function venvPython() {
38
- return path.join(VENV_DIR, 'bin', 'python3');
39
- }
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');
46
- if (!fs.existsSync(snapshotsDir))
47
- return false;
48
- return fs.readdirSync(snapshotsDir).length > 0;
49
- }
50
- let currentStep = 'not_running';
51
- let downloadProgress;
52
- let lastError;
53
- let managedProcess = null;
54
- let setupPromise = null;
55
- // セットアップ中・tsunagi管理で起動中のモデル
56
- let targetModel = null;
57
- function sleep(ms) {
58
- return new Promise((resolve) => setTimeout(resolve, ms));
59
- }
60
- // 起動していれば読み込み中のモデルのリポジトリIDを返す(未起動ならnull)。
61
- // tsunagi外で起動されたサーバーでも、どのモデルで動いているかを知るために/healthから取る。
62
- async function checkHealth() {
63
- try {
64
- const response = await fetch(`${WHISPER_SERVER_URL}/health`, {
65
- signal: AbortSignal.timeout(2000),
66
- });
67
- if (!response.ok)
68
- return null;
69
- const body = (await response.json().catch(() => ({})));
70
- return { repo: typeof body.model === 'string' ? body.model : null };
71
- }
72
- catch {
73
- return null;
74
- }
75
- }
76
- export async function getWhisperServerStatus() {
77
- const serverDir = findWhisperServerDir();
78
- // セットアップ/起動フロー進行中はそのステップをそのまま報告する。
79
- if (setupPromise) {
80
- return {
81
- step: currentStep,
82
- serverDir,
83
- modelId: targetModel?.id ?? null,
84
- downloadProgress,
85
- error: lastError,
86
- };
87
- }
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
- };
95
- }
96
- return {
97
- step: lastError ? 'error' : 'not_running',
98
- serverDir,
99
- modelId: null,
100
- error: lastError,
101
- };
102
- }
103
- function runStep(cmd, args, cwd, env) {
104
- return new Promise((resolve, reject) => {
105
- const child = spawn(cmd, args, { cwd, stdio: 'ignore', env: env ?? process.env });
106
- child.on('exit', (code) => {
107
- if (code === 0)
108
- resolve();
109
- else
110
- reject(new Error(`${path.basename(cmd)} ${args.join(' ')} exited with code ${code}`));
111
- });
112
- child.on('error', reject);
113
- });
114
- }
115
- // モデルDL中(huggingface_hubがblobs/配下に *.incomplete を書き続ける)のファイルサイズを
116
- // 定期的にポーリングし、進捗(%)と直近の転送速度からのETAを算出する。
117
- async function trackModelDownload(child, model) {
118
- const blobsDir = path.join(modelCacheDir(model.repo), 'blobs');
119
- let lastBytes = 0;
120
- let lastTime = Date.now();
121
- let stopped = false;
122
- child.on('exit', () => {
123
- stopped = true;
124
- });
125
- while (!stopped) {
126
- await sleep(1000);
127
- let downloaded = 0;
128
- try {
129
- for (const f of fs.readdirSync(blobsDir)) {
130
- if (!f.endsWith('.incomplete'))
131
- continue;
132
- downloaded += fs.statSync(path.join(blobsDir, f)).size;
133
- }
134
- }
135
- catch {
136
- // blobsDir がまだ作られていない場合は0のまま次のポーリングへ
137
- }
138
- const now = Date.now();
139
- const elapsedSec = (now - lastTime) / 1000;
140
- const bytesPerSec = elapsedSec > 0 ? (downloaded - lastBytes) / elapsedSec : 0;
141
- const remaining = model.expectedBytes - downloaded;
142
- // 転送が一時的に止まって見える(ポーリング間隔とディスク書き込みのタイミングがずれる)だけで
143
- // 速度0になることがあるため、その場合は直前のETAを維持し「計算中...」への逆戻りを防ぐ。
144
- const etaSeconds = bytesPerSec > 0
145
- ? Math.max(0, Math.round(remaining / bytesPerSec))
146
- : (downloadProgress?.etaSeconds ?? null);
147
- downloadProgress = {
148
- downloadedBytes: downloaded,
149
- totalBytes: model.expectedBytes,
150
- etaSeconds,
151
- };
152
- lastBytes = downloaded;
153
- lastTime = now;
154
- }
155
- }
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
- };
164
- const requirementsFile = path.join(dir, 'requirements.txt');
165
- if (!isVenvUpToDate(VENV_DIR, requirementsFile)) {
166
- currentStep = 'installing_deps';
167
- const pythonBin = findPython();
168
- if (!pythonBin)
169
- throw new Error(PYTHON_NOT_FOUND_MESSAGE);
170
- fs.mkdirSync(TSUNAGI_WHISPER_DIR, { recursive: true });
171
- fs.rmSync(VENV_DIR, { recursive: true, force: true });
172
- await runStep(pythonBin, ['-m', 'venv', VENV_DIR], dir);
173
- await runStep(venvPython(), ['-m', 'pip', 'install', '-r', 'requirements.txt'], dir);
174
- writeVenvMarker(VENV_DIR, requirementsFile);
175
- }
176
- if (!isModelReady(model.repo)) {
177
- currentStep = 'downloading_model';
178
- downloadProgress = { downloadedBytes: 0, totalBytes: model.expectedBytes, etaSeconds: null };
179
- await new Promise((resolve, reject) => {
180
- const child = spawn(venvPython(), ['download_model.py', model.repo], {
181
- cwd: dir,
182
- stdio: 'ignore',
183
- env: hfEnv,
184
- });
185
- void trackModelDownload(child, model);
186
- child.on('exit', (code) => {
187
- if (code === 0)
188
- resolve();
189
- else
190
- reject(new Error(`download_model.py exited with code ${code}`));
191
- });
192
- child.on('error', reject);
193
- });
194
- downloadProgress = undefined;
195
- }
196
- currentStep = 'starting_server';
197
- const child = spawn(venvPython(), ['-m', 'uvicorn', 'server:app', '--host', '127.0.0.1', '--port', '8765'], { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'], env: hfEnv });
198
- let stderrTail = '';
199
- child.stderr?.on('data', (chunk) => {
200
- stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_MAX_CHARS);
201
- });
202
- // healthポーリングとは別に、プロセスが起動途中で終了したことをタイムアウトを待たず検知する。
203
- let exitInfo = null;
204
- child.on('exit', (code, signal) => {
205
- exitInfo = `exit code=${code} signal=${signal}`;
206
- if (managedProcess === child)
207
- managedProcess = null;
208
- });
209
- child.on('error', (err) => {
210
- exitInfo = `spawn error: ${err.message}`;
211
- if (managedProcess === child)
212
- managedProcess = null;
213
- });
214
- managedProcess = child;
215
- const start = Date.now();
216
- while (Date.now() - start < STARTUP_TIMEOUT_MS) {
217
- if (exitInfo) {
218
- throw new Error(`whisper-server crashed while starting (${exitInfo})${stderrTail ? `\n${stderrTail}` : ''}`);
219
- }
220
- if (await checkHealth())
221
- return;
222
- await sleep(1000);
223
- }
224
- throw new Error(`Server did not become healthy within ${STARTUP_TIMEOUT_MS / 1000}s of starting`);
225
- }
226
- // 設定で選択中のモデルでセットアップ・起動する。
227
- export async function startWhisperServer() {
228
- if (setupPromise || managedProcess) {
229
- return { started: false, error: 'Already starting/running' };
230
- }
231
- const dir = findWhisperServerDir();
232
- if (!dir) {
233
- return { started: false, error: 'whisper-server directory not found' };
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
- }
242
- lastError = undefined;
243
- targetModel = model;
244
- setupPromise = runSetupAndStart(dir, model)
245
- .catch((error) => {
246
- currentStep = 'error';
247
- lastError = error instanceof Error ? error.message : String(error);
248
- })
249
- .finally(() => {
250
- setupPromise = null;
251
- });
252
- return { started: true };
253
- }
254
- export async function stopWhisperServer() {
255
- if (managedProcess) {
256
- managedProcess.kill();
257
- managedProcess = null;
258
- targetModel = null;
259
- currentStep = 'not_running';
260
- return { stopped: true };
261
- }
262
- // tsunagi外(make whisper等)で起動された場合はchild_processのハンドルを
263
- // 持たないため、ポート番号を手がかりにOS側から見つけて停止する。
264
- const killed = await killProcessOnPort(WHISPER_PORT);
265
- if (!killed) {
266
- return {
267
- stopped: false,
268
- error: `ポート${WHISPER_PORT}で待ち受けているプロセスが見つかりませんでした`,
269
- };
270
- }
271
- currentStep = 'not_running';
272
- return { stopped: true };
273
- }
274
- // tsunagi本体プロセスの終了時(Ctrl+C等)に、自分が起動したwhisper-serverも
275
- // 道連れで停止する。ユーザーが手動で起動したもの(running_external)には触れない。
276
- export function stopWhisperServerOnExit() {
277
- managedProcess?.kill();
278
- managedProcess = null;
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,2 +0,0 @@
1
- import type { FastifyInstance } from 'fastify';
2
- export declare function whisperRoutes(fastify: FastifyInstance): Promise<void>;
@@ -1,103 +0,0 @@
1
- import { deleteAsrModel, getWhisperServerStatus, listAsrModels, selectAsrModel, startWhisperServer, stopWhisperServer, } from '../lib/whisper-process.js';
2
- import { appendVoiceDebugLog } from '../lib/voice-debug-log.js';
3
- // ローカル常駐の音声認識サーバー(apps/whisper-server)を指す。
4
- // 読み込むモデル(Whisper / Qwen3-ASR)は起動時に whisper-process 側で指定する。
5
- const WHISPER_SERVER_URL = process.env.TSUNAGI_WHISPER_SERVER_URL || 'http://127.0.0.1:8765';
6
- export async function whisperRoutes(fastify) {
7
- // GET /whisper/server/status
8
- fastify.get('/whisper/server/status', async (_request, reply) => {
9
- return reply.status(200).send(await getWhisperServerStatus());
10
- });
11
- // POST /whisper/server/start
12
- fastify.post('/whisper/server/start', async (_request, reply) => {
13
- const result = await startWhisperServer();
14
- if (!result.started) {
15
- return reply.status(409).send({ error: result.error });
16
- }
17
- return reply.status(202).send(await getWhisperServerStatus());
18
- });
19
- // POST /whisper/server/stop
20
- fastify.post('/whisper/server/stop', async (_request, reply) => {
21
- const result = await stopWhisperServer();
22
- if (!result.stopped) {
23
- return reply.status(409).send({ error: result.error });
24
- }
25
- return reply.status(200).send(await getWhisperServerStatus());
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
- });
51
- // POST /whisper/transcribe
52
- fastify.post('/whisper/transcribe', async (request, reply) => {
53
- const file = await request.file();
54
- if (!file) {
55
- return reply.status(400).send({ error: 'No audio file provided' });
56
- }
57
- // 音声ファイルと同じmultipartペイロード内の`prompt`フィールド(あれば)を
58
- // whisper-serverのinitial_promptとしてそのまま転送する。
59
- const promptField = file.fields.prompt;
60
- const prompt = typeof promptField?.value === 'string' ? promptField.value : undefined;
61
- // 発話中の途中経過表示用のリクエスト。確定前の暫定結果なので、
62
- // デバッグログはスキップする。
63
- const interimField = file.fields.interim;
64
- const interim = interimField?.value === 'true';
65
- const buffer = await file.toBuffer();
66
- const body = new FormData();
67
- body.append('file', new Blob([buffer]), file.filename);
68
- if (prompt)
69
- body.append('prompt', prompt);
70
- let whisperText;
71
- let model;
72
- const startedAt = Date.now();
73
- try {
74
- const response = await fetch(`${WHISPER_SERVER_URL}/transcribe`, {
75
- method: 'POST',
76
- body,
77
- });
78
- if (!response.ok) {
79
- throw new Error(`whisper-server responded with ${response.status}`);
80
- }
81
- const result = (await response.json());
82
- whisperText = result.text;
83
- model = result.model;
84
- }
85
- catch (error) {
86
- fastify.log.error(error, 'Whisper transcription error');
87
- return reply.status(502).send({
88
- error: error instanceof Error
89
- ? error.message
90
- : 'Transcription failed. Is apps/whisper-server running?',
91
- });
92
- }
93
- if (!interim) {
94
- appendVoiceDebugLog({
95
- model,
96
- elapsedMs: Date.now() - startedAt,
97
- whisperPrompt: prompt,
98
- whisperText,
99
- });
100
- }
101
- return reply.status(200).send({ text: whisperText });
102
- });
103
- }
@@ -1,31 +0,0 @@
1
- # whisper-server
2
-
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` で定義する。
12
- tsunagi本体はこのサーバーにHTTPでプロキシするだけで、セットアップ・起動は行わない。
13
-
14
- ## 要件
15
-
16
- - macOS (Apple Silicon: M1/M2/M3/M4)
17
- - Python 3.9+ (Xcode Command Line Tools または Homebrew 経由で入手可能)
18
-
19
- ffmpeg等の外部バイナリは不要(音声はブラウザ側のVADで発話区間だけ切り出し、16kHzモノラルWAVで送られる)。
20
-
21
- ## セットアップ・起動
22
-
23
- ```bash
24
- cd apps/whisper-server
25
- ./run.sh
26
- ```
27
-
28
- 初回はvenvを作成し`requirements.txt`の依存関係(mlx-whisper, mlx-qwen3-asr, fastapi, uvicorn等)をインストールしてから起動する。
29
- `http://127.0.0.1:8765`で待受。初回起動時に指定モデルを自動ダウンロードする。
30
-
31
- 起動している間、tsunagiのSettingsで音声入力を有効にすると利用できる。停止するとtsunagi側の音声入力はエラーになる。
@@ -1,19 +0,0 @@
1
- """音声認識モデルの重みを事前ダウンロードするだけのスクリプト。
2
-
3
- 使い方: python3 download_model.py <Hugging FaceのリポジトリID>
4
- (省略時はWhisper large-v3-turbo)
5
-
6
- サーバー起動(server.py)より前に呼ぶことで、初回の文字起こしリクエストで
7
- 突然数分待たされる、という事態を避ける。tsunagi(Node)側からはこのプロセスの
8
- 実行中、キャッシュディレクトリ内の.incompleteファイルのサイズを定期的に見て
9
- 進捗を計算する想定なので、ここでは特別な進捗出力はしない。
10
- """
11
-
12
- import sys
13
-
14
- from huggingface_hub import snapshot_download
15
-
16
- DEFAULT_MODEL_REPO = "mlx-community/whisper-large-v3-turbo"
17
-
18
- if __name__ == "__main__":
19
- snapshot_download(repo_id=sys.argv[1] if len(sys.argv) > 1 else DEFAULT_MODEL_REPO)
@@ -1,8 +0,0 @@
1
- mlx-whisper==0.4.3
2
- mlx-qwen3-asr==0.4.4
3
- mlx==0.32.0
4
- fastapi
5
- uvicorn[standard]
6
- python-multipart
7
- numpy
8
- av
@@ -1,62 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- cd "$(dirname "${BASH_SOURCE[0]}")"
4
-
5
- # venv・モデルキャッシュとも ~/.tsunagi/whisper 配下にまとめる。
6
- # npmインストール先(apps/whisper-server自体)が書き込み不可な場所でも動くようにする狙いもある。
7
- TSUNAGI_WHISPER_DIR="${HOME}/.tsunagi/whisper"
8
- VENV_DIR="${TSUNAGI_WHISPER_DIR}/venv"
9
- export HF_HOME="${TSUNAGI_WHISPER_DIR}/cache"
10
- # xet転送は進捗の観測が難しい独自プロトコルのため無効化し、素直なHTTPダウンロードに固定する。
11
- export HF_HUB_DISABLE_XET=1
12
-
13
- # requirements.txtでpinしているmlxがrequires-python >=3.10のため、venv作成に使うpython3自体も
14
- # 3.10以上でなければpip installがそもそも失敗する。`python3`という名前がPATH上のどのバージョンを
15
- # 指すかは環境依存(古いmacOS付属Python等)なため、候補を新しい順に探して最初に見つかった
16
- # 3.10以上を使う。
17
- MIN_PYTHON_VERSION="3.10"
18
-
19
- find_python() {
20
- for cand in python3.14 python3.13 python3.12 python3.11 python3.10 python3; do
21
- if command -v "$cand" >/dev/null 2>&1; then
22
- ver="$("$cand" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)"
23
- [ -z "$ver" ] && continue
24
- if [ "$(printf '%s\n%s\n' "$MIN_PYTHON_VERSION" "$ver" | sort -V | head -n1)" = "$MIN_PYTHON_VERSION" ]; then
25
- echo "$cand"
26
- return 0
27
- fi
28
- fi
29
- done
30
- return 1
31
- }
32
-
33
- if ! PYTHON_BIN="$(find_python)"; then
34
- echo "音声入力サーバーの起動に失敗: Python ${MIN_PYTHON_VERSION}以上が見つかりません。" >&2
35
- echo "'brew install python@3.12' 等でインストールしてください。" >&2
36
- exit 1
37
- fi
38
-
39
- # requirements.txtの内容、または使用するpython3のバージョンが変わった場合は既存venvごと作り直す。
40
- # venvは作成時のpythonバイナリに紐づくため、pip installだけでは古いバージョンのまま更新できない。
41
- REQUIREMENTS_HASH_FILE="${VENV_DIR}/.requirements.sha256"
42
- CURRENT_HASH="$( { cat requirements.txt; "$PYTHON_BIN" --version; } | shasum -a 256 | awk '{print $1}')"
43
-
44
- if [ ! -d "$VENV_DIR" ] || [ "$(cat "$REQUIREMENTS_HASH_FILE" 2>/dev/null)" != "$CURRENT_HASH" ]; then
45
- mkdir -p "$TSUNAGI_WHISPER_DIR"
46
- rm -rf "$VENV_DIR"
47
- "$PYTHON_BIN" -m venv "$VENV_DIR"
48
- "$VENV_DIR/bin/python3" -m pip install -r requirements.txt
49
- echo "$CURRENT_HASH" > "$REQUIREMENTS_HASH_FILE"
50
- fi
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
-
57
- # サーバー起動前にモデルを予めダウンロードしておく(初回文字起こしで待たされないように)。
58
- "$VENV_DIR/bin/python3" download_model.py "$TSUNAGI_ASR_MODEL"
59
-
60
- # venvを ~/.tsunagi 配下へ移設する場合があるため、絶対パスが焼き込まれる
61
- # コンソールスクリプト(bin/uvicorn等)ではなく `python3 -m` 経由で呼び出す。
62
- exec "$VENV_DIR/bin/python3" -m uvicorn server:app --host 127.0.0.1 --port 8765