mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { existsSync } from 'node:fs'
|
|
import { cp, mkdir, readdir, rename, stat } from 'node:fs/promises'
|
|
import { join, resolve } from 'node:path'
|
|
|
|
export type SpeechModelCacheDir = {
|
|
modelsDir: string
|
|
migrationSourceDir: string | null
|
|
}
|
|
|
|
const WINDOWS_SAFE_CACHE_HASH_LENGTH = 16
|
|
|
|
function hasNonAsciiCharacters(value: string): boolean {
|
|
for (const character of value) {
|
|
if (character.charCodeAt(0) > 0x7f) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function getWindowsAsciiSharedDataRoots(): string[] {
|
|
const publicDir = process.env.PUBLIC
|
|
const systemDriveProgramData = process.env.SystemDrive
|
|
? `${process.env.SystemDrive}\\ProgramData`
|
|
: undefined
|
|
const candidates = [
|
|
process.env.PROGRAMDATA,
|
|
process.env.ProgramData,
|
|
process.env.ALLUSERSPROFILE,
|
|
process.env.PUBLIC ? join(process.env.PUBLIC, 'Documents') : undefined,
|
|
publicDir,
|
|
systemDriveProgramData,
|
|
'C:\\ProgramData'
|
|
]
|
|
const roots: string[] = []
|
|
for (const candidate of candidates) {
|
|
if (!candidate || hasNonAsciiCharacters(candidate) || roots.includes(candidate)) {
|
|
continue
|
|
}
|
|
roots.push(candidate)
|
|
}
|
|
return roots
|
|
}
|
|
|
|
export function getSpeechModelCacheDirCandidates(
|
|
requestedModelsDir: string
|
|
): SpeechModelCacheDir[] {
|
|
if (process.platform !== 'win32' || !hasNonAsciiCharacters(requestedModelsDir)) {
|
|
return [{ modelsDir: requestedModelsDir, migrationSourceDir: null }]
|
|
}
|
|
|
|
const requestedModelsDirHash = createHash('sha256')
|
|
.update(resolve(requestedModelsDir))
|
|
.digest('hex')
|
|
.slice(0, WINDOWS_SAFE_CACHE_HASH_LENGTH)
|
|
const candidates = getWindowsAsciiSharedDataRoots()
|
|
.map((root) => join(root, 'Orca', 'speech-models', requestedModelsDirHash))
|
|
.filter((modelsDir) => !hasNonAsciiCharacters(modelsDir))
|
|
.map((modelsDir) => ({ modelsDir, migrationSourceDir: requestedModelsDir }))
|
|
|
|
// Why: sherpa-onnx 1.12.x cannot load model files from non-ASCII Windows
|
|
// paths. Try ASCII shared caches first, but keep the requested path as a
|
|
// last fallback so cache setup failures do not prevent the app from opening.
|
|
return [...candidates, { modelsDir: requestedModelsDir, migrationSourceDir: null }]
|
|
}
|
|
|
|
async function copyMissingCacheEntry(sourcePath: string, targetPath: string): Promise<void> {
|
|
const sourceStat = await stat(sourcePath)
|
|
if (sourceStat.isDirectory()) {
|
|
await mkdir(targetPath, { recursive: true })
|
|
for (const entry of await readdir(sourcePath, { withFileTypes: true })) {
|
|
await copyMissingCacheEntry(join(sourcePath, entry.name), join(targetPath, entry.name))
|
|
}
|
|
return
|
|
}
|
|
|
|
if (existsSync(targetPath)) {
|
|
return
|
|
}
|
|
|
|
// Why: copy to a temp path and atomically rename so an interrupted migration
|
|
// never leaves a truncated model file that passes existence-only validation.
|
|
const tempPath = `${targetPath}.partial`
|
|
await cp(sourcePath, tempPath, { force: true })
|
|
await rename(tempPath, targetPath)
|
|
}
|
|
|
|
export async function migrateSpeechModelCacheIfNeeded(
|
|
sourceDir: string | null,
|
|
targetDir: string
|
|
): Promise<void> {
|
|
if (!sourceDir || resolve(sourceDir) === resolve(targetDir) || !existsSync(sourceDir)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
for (const entry of await readdir(sourceDir, { withFileTypes: true })) {
|
|
await copyMissingCacheEntry(join(sourceDir, entry.name), join(targetDir, entry.name))
|
|
}
|
|
} catch (error) {
|
|
console.warn('[speech] Failed to migrate speech model cache to ASCII path:', error)
|
|
}
|
|
}
|