2025-11-23 15:54:59 +09:00
import Foundation
import OnnxRuntimeBindings
final class TTSService {
enum Voice { case male , female }
struct SynthesisResult {
let url : URL
let elapsedSeconds : Double
let audioSeconds : Double
var rtf : Double { elapsedSeconds / max ( audioSeconds , 1e-6 ) }
}
private let env : ORTEnv
private let textToSpeech : TextToSpeech
private let bundleOnnxDir : String
private let sampleRate : Int
// C a c h e d s t y l e p e r v o i c e ( p r e c o m p u t e d a t s t a r t u p o r o n f i r s t u s e )
private var cachedStyle : [ Voice : Style ] = [ : ]
init ( ) throws {
bundleOnnxDir = try Self . locateOnnxDirInBundle ( )
env = try ORTEnv ( loggingLevel : . warning )
textToSpeech = try loadTextToSpeech ( bundleOnnxDir , false , env )
sampleRate = textToSpeech . sampleRate
}
// P u b l i c w a r m u p : p r e c o m p u t e s t y l e s a n d r u n a q u i c k g e n e r a t i o n t o w a r m m o d e l s
func warmup ( nfe : Int = 1 ) async {
do { try precomputeStyle ( for : . male ) } catch { print ( " Warmup style (M) error: \( error ) " ) }
do { try precomputeStyle ( for : . female ) } catch { print ( " Warmup style (F) error: \( error ) " ) }
// R u n a t i n y s y n t h e s i s t o J I T / w a r m u p k e r n e l s ; d i s c a r d f i l e
do {
let res = try await synthesize ( text : " Warm up " , nfe : max ( 1 , nfe ) , voice : . male )
try ? FileManager . default . removeItem ( at : res . url )
} catch {
print ( " Warmup synth error: \( error ) " )
}
}
2025-11-26 10:55:36 +09:00
func synthesize ( text : String , nfe : Int , voice : Voice ) async throws -> SynthesisResult {
2025-11-23 15:54:59 +09:00
let tic = Date ( )
// 1 ) G e t o r c o m p u t e s t y l e f o r t h e s e l e c t e d v o i c e
let style = try getStyle ( voice : voice )
// 2 ) S y n t h e s i z e v i a p a c k e d T e x t T o S p e e c h c o m p o n e n t
2025-11-26 10:55:36 +09:00
let ( wav , duration ) = try textToSpeech . call ( text , style , nfe )
let audioSeconds = Double ( duration )
2025-11-23 15:54:59 +09:00
let wavLenSample = min ( Int ( Double ( sampleRate ) * audioSeconds ) , wav . count )
let wavOut = Array ( wav [ 0. . < wavLenSample ] )
let tmpURL = FileManager . default . temporaryDirectory . appendingPathComponent ( " supertonic_tts_ \( UUID ( ) . uuidString ) .wav " )
try writeWavFile ( tmpURL . path , wavOut , sampleRate )
let elapsed = Date ( ) . timeIntervalSince ( tic )
return SynthesisResult ( url : tmpURL , elapsedSeconds : elapsed , audioSeconds : audioSeconds )
}
// MARK: - S t y l e h e l p e r s
private func precomputeStyle ( for voice : Voice ) throws {
if cachedStyle [ voice ] != nil { return }
let styleURL = try Self . locateVoiceStyleURL ( voice : voice )
let style = try loadVoiceStyle ( [ styleURL . path ] , verbose : false )
cachedStyle [ voice ] = style
}
private func getStyle ( voice : Voice ) throws -> Style {
if let style = cachedStyle [ voice ] { return style }
try precomputeStyle ( for : voice )
return cachedStyle [ voice ] !
}
// MARK: - R e s o u r c e l o c a t i o n h e l p e r s
private static func locateOnnxDirInBundle ( ) throws -> String {
let bundle = Bundle . main
let fm = FileManager . default
func dirHasRequiredFiles ( _ dir : URL ) -> Bool {
let required = [
" tts.json " ,
" duration_predictor.onnx " ,
" text_encoder.onnx " ,
" vector_estimator.onnx " ,
" vocoder.onnx "
]
return required . allSatisfy { fm . fileExists ( atPath : dir . appendingPathComponent ( $0 ) . path ) }
}
var candidates : [ URL ] = [ ]
if let dir = bundle . resourceURL ? . appendingPathComponent ( " onnx " , isDirectory : true ) { candidates . append ( dir ) }
if let dir = bundle . resourceURL ? . appendingPathComponent ( " assets/onnx " , isDirectory : true ) { candidates . append ( dir ) }
if let url = bundle . url ( forResource : " tts " , withExtension : " json " , subdirectory : " onnx " ) { candidates . append ( url . deletingLastPathComponent ( ) ) }
if let url = bundle . url ( forResource : " tts " , withExtension : " json " , subdirectory : " assets/onnx " ) { candidates . append ( url . deletingLastPathComponent ( ) ) }
if let url = bundle . url ( forResource : " tts " , withExtension : " json " , subdirectory : nil ) { candidates . append ( url . deletingLastPathComponent ( ) ) }
if let root = bundle . resourceURL { candidates . append ( root ) }
for dir in candidates {
if dirHasRequiredFiles ( dir ) { return dir . path }
}
throw NSError (
domain : " TTS " ,
code : - 100 ,
userInfo : [ NSLocalizedDescriptionKey : " Could not find the onnx directory in the bundle. Please make sure the onnx folder (as a folder reference) is included in Copy Bundle Resources in Xcode. " ]
)
}
private static func locateVoiceStyleURL ( voice : Voice ) throws -> URL {
// P r e f e r M 1 / F 1 d e f a u l t s ; s e a r c h c o m m o n s u b d i r e c t o r i e s
let fileName = ( voice = = . male ) ? " M1 " : " F1 "
let bundle = Bundle . main
let candidates : [ URL ? ] = [
bundle . url ( forResource : fileName , withExtension : " json " , subdirectory : " voice_styles " ) ,
bundle . url ( forResource : fileName , withExtension : " json " , subdirectory : " assets/voice_styles " ) ,
bundle . url ( forResource : fileName , withExtension : " json " , subdirectory : nil )
]
for url in candidates {
if let url = url { return url }
}
// F a l l b a c k : s c a n f o l d e r s i f n e e d e d
if let folder1 = bundle . resourceURL ? . appendingPathComponent ( " voice_styles " , isDirectory : true ) {
let file = folder1 . appendingPathComponent ( " \( fileName ) .json " )
if FileManager . default . fileExists ( atPath : file . path ) { return file }
}
if let folder2 = bundle . resourceURL ? . appendingPathComponent ( " assets/voice_styles " , isDirectory : true ) {
let file = folder2 . appendingPathComponent ( " \( fileName ) .json " )
if FileManager . default . fileExists ( atPath : file . path ) { return file }
}
throw NSError (
domain : " TTS " ,
code : - 102 ,
userInfo : [ NSLocalizedDescriptionKey : " Could not find the voice style JSON ( \( fileName ) .json) in the bundle. Ensure voice_styles folder is included in Copy Bundle Resources. " ]
)
}
}