11#!/usr/bin/env node
2- import { existsSync , mkdirSync , readFileSync , readdirSync , statSync } from 'node:fs'
2+ import { createHash } from 'node:crypto'
3+ import { existsSync , mkdirSync , readFileSync , statSync , writeFileSync } from 'node:fs'
34import { join } from 'node:path'
45import { spawn , spawnSync } from 'node:child_process'
6+ import { devNull } from 'node:os'
57
68const root = process . cwd ( )
79const strict = process . env . GOAGENT_TTS_SMOKE_STRICT === '1'
810const assetRoot = join ( root , 'data' , 'tts' , 'kokoro' , 'zh-CN' )
9- const cacheRoot = join ( root , process . env . GOAGENT_APP_HOME || '.goagent-smoke' , 'cache' , 'tts' , 'kokoro-bundled' )
11+ const smokeHome = join ( root , process . env . GOAGENT_APP_HOME || '.goagent-smoke' )
12+ const cacheRoot = join ( smokeHome , 'cache' , 'tts' , 'kokoro-bundled' )
13+ const runtimeRoot = join ( smokeHome , 'runtime' , 'tts-python' )
14+ const venvRoot = join ( runtimeRoot , 'venv' )
15+ const venvPython = join ( venvRoot , process . platform === 'win32' ? 'Scripts/python.exe' : 'bin/python3' )
16+ const requirementsPath = join ( root , 'scripts' , 'requirements-tts.txt' )
17+ const requirementsStamp = join ( runtimeRoot , 'requirements.sha256' )
1018const failures = [ ]
1119let strictSynthesisOk = false
1220
@@ -29,38 +37,125 @@ function splitLauncher(commandLine) {
2937 return { command : commandLine , args : [ ] }
3038}
3139
32- function runMisakiG2p ( text ) {
33- const script = join ( root , 'scripts' , 'tts_misaki_zh_g2p.py' )
40+ function spawnChecked ( command , args , options = { } ) {
41+ const result = spawnSync ( command , args , {
42+ encoding : 'utf8' ,
43+ maxBuffer : 1024 * 1024 ,
44+ ...options
45+ } )
46+ if ( result . status !== 0 ) {
47+ const detail = result . stderr ?. trim ( ) || result . stdout ?. trim ( ) || `exit ${ result . status } `
48+ throw new Error ( detail )
49+ }
50+ return result
51+ }
52+
53+ function isSupportedPython ( launcher ) {
54+ const result = spawnSync ( launcher . command , [
55+ ...launcher . args ,
56+ '-c' ,
57+ 'import sys; raise SystemExit(0 if sys.version_info.major == 3 and 10 <= sys.version_info.minor <= 13 else 1)'
58+ ] , { encoding : 'utf8' } )
59+ return result . status === 0
60+ }
61+
62+ function hasMisakiZh ( launcher ) {
63+ const result = spawnSync ( launcher . command , [
64+ ...launcher . args ,
65+ '-c' ,
66+ 'from misaki.zh import ZHG2P; ZHG2P(version="1.1"); print("ok")'
67+ ] , {
68+ encoding : 'utf8' ,
69+ maxBuffer : 1024 * 1024 ,
70+ env : {
71+ ...process . env ,
72+ PYTHONIOENCODING : 'utf-8'
73+ }
74+ } )
75+ return result . status === 0
76+ }
77+
78+ function pipEnv ( ) {
79+ return {
80+ ...process . env ,
81+ PIP_CONFIG_FILE : devNull ,
82+ PIP_INDEX_URL : 'https://pypi.org/simple' ,
83+ PIP_DISABLE_PIP_VERSION_CHECK : '1' ,
84+ PIP_NO_INPUT : '1'
85+ }
86+ }
87+
88+ function ensureSmokePythonRuntime ( baseLauncher ) {
89+ mkdirSync ( runtimeRoot , { recursive : true } )
90+ if ( ! existsSync ( venvPython ) ) {
91+ spawnChecked ( baseLauncher . command , [ ...baseLauncher . args , '-m' , 'venv' , venvRoot ] , { timeout : 120_000 } )
92+ }
93+ const venvLauncher = { command : venvPython , args : [ ] }
94+ if ( ! isSupportedPython ( venvLauncher ) ) {
95+ throw new Error ( `smoke TTS venv is not Python 3.10-3.13: ${ venvPython } ` )
96+ }
97+
98+ const requirements = readFileSync ( requirementsPath , 'utf8' )
99+ const digest = createHash ( 'sha256' ) . update ( requirements ) . digest ( 'hex' )
100+ const installedDigest = existsSync ( requirementsStamp ) ? readFileSync ( requirementsStamp , 'utf8' ) . trim ( ) : ''
101+ if ( ! hasMisakiZh ( venvLauncher ) || installedDigest !== digest ) {
102+ spawnChecked ( venvPython , [ '-m' , 'ensurepip' , '--upgrade' ] , { timeout : 120_000 } )
103+ try {
104+ spawnChecked ( venvPython , [ '-m' , 'pip' , 'install' , '-r' , requirementsPath ] , { timeout : 360_000 , env : pipEnv ( ) } )
105+ } catch ( firstError ) {
106+ spawnChecked ( venvPython , [ '-m' , 'pip' , 'install' , '-r' , requirementsPath ] , { timeout : 360_000 } )
107+ }
108+ writeFileSync ( requirementsStamp , `${ digest } \n` , 'utf8' )
109+ }
110+ if ( ! hasMisakiZh ( venvLauncher ) ) {
111+ throw new Error ( `misaki[zh] is still unavailable after installing ${ requirementsPath } ` )
112+ }
113+ return venvLauncher
114+ }
115+
116+ function resolveMisakiPython ( ) {
34117 const errors = [ ]
118+ let firstSupported = null
35119 for ( const candidate of pythonCandidates ( ) ) {
36120 const launcher = splitLauncher ( candidate )
37- const probe = spawnSync ( launcher . command , [
38- ...launcher . args ,
39- '-c' ,
40- 'import sys; raise SystemExit(0 if sys.version_info.major == 3 and 10 <= sys.version_info.minor <= 13 else 1)'
41- ] , { encoding : 'utf8' } )
42- if ( probe . status !== 0 ) {
121+ if ( ! isSupportedPython ( launcher ) ) {
43122 errors . push ( `${ candidate } : not Python 3.10-3.13` )
44123 continue
45124 }
46- const result = spawnSync ( launcher . command , [ ...launcher . args , script ] , {
47- input : JSON . stringify ( { text } ) ,
48- encoding : 'utf8' ,
49- maxBuffer : 1024 * 1024 ,
50- env : {
51- ...process . env ,
52- PYTHONIOENCODING : 'utf-8' ,
53- GOAGENT_TTS_ALLOW_UNKNOWN_PHONEMES : '1'
54- }
55- } )
56- if ( result . status === 0 ) {
57- const jsonLine = result . stdout . split ( / \r ? \n / ) . map ( ( line ) => line . trim ( ) ) . filter ( Boolean ) . reverse ( ) . find ( ( line ) => line . startsWith ( '{' ) && line . endsWith ( '}' ) )
58- if ( ! jsonLine ) throw new Error ( `Misaki zh G2P returned no JSON: ${ result . stdout } ` )
59- return JSON . parse ( jsonLine )
125+ firstSupported ??= { ...launcher , label : candidate }
126+ if ( hasMisakiZh ( launcher ) ) return { ...launcher , label : candidate }
127+ errors . push ( `${ candidate } : Python OK, misaki[zh] not installed` )
128+ }
129+ if ( ! firstSupported ) {
130+ throw new Error ( `Misaki zh G2P unavailable. Tried ${ errors . join ( ';' ) } ` )
131+ }
132+ try {
133+ return ensureSmokePythonRuntime ( firstSupported )
134+ } catch ( error ) {
135+ errors . push ( `managed smoke venv: ${ error instanceof Error ? error . message : String ( error ) } ` )
136+ throw new Error ( `Misaki zh G2P unavailable. Tried ${ errors . join ( ';' ) } ` )
137+ }
138+ }
139+
140+ function runMisakiG2p ( text ) {
141+ const script = join ( root , 'scripts' , 'tts_misaki_zh_g2p.py' )
142+ const launcher = resolveMisakiPython ( )
143+ const result = spawnSync ( launcher . command , [ ...launcher . args , script ] , {
144+ input : JSON . stringify ( { text } ) ,
145+ encoding : 'utf8' ,
146+ maxBuffer : 1024 * 1024 ,
147+ env : {
148+ ...process . env ,
149+ PYTHONIOENCODING : 'utf-8' ,
150+ GOAGENT_TTS_ALLOW_UNKNOWN_PHONEMES : '1'
60151 }
61- errors . push ( `${ candidate } : ${ result . stderr . trim ( ) || result . stdout . trim ( ) || `exit ${ result . status } ` } ` )
152+ } )
153+ if ( result . status === 0 ) {
154+ const jsonLine = result . stdout . split ( / \r ? \n / ) . map ( ( line ) => line . trim ( ) ) . filter ( Boolean ) . reverse ( ) . find ( ( line ) => line . startsWith ( '{' ) && line . endsWith ( '}' ) )
155+ if ( ! jsonLine ) throw new Error ( `Misaki zh G2P returned no JSON: ${ result . stdout } ` )
156+ return JSON . parse ( jsonLine )
62157 }
63- throw new Error ( `Misaki zh G2P unavailable. Tried ${ errors . join ( ';' ) } ` )
158+ throw new Error ( `${ launcher . label ?? launcher . command } : ${ result . stderr . trim ( ) || result . stdout . trim ( ) || `exit ${ result . status } ` } ` )
64159}
65160
66161function inspectWavAudio ( path ) {
0 commit comments