2025-11-23 15:54:59 +09:00
import Foundation
import OnnxRuntimeBindings
final class TTSService {
enum Voice { case male , female }
private let env : ORTEnv
private let textToSpeech : TextToSpeech
private let bundleOnnxDir : String
private let sampleRate : Int
init ( ) throws {
bundleOnnxDir = try Self . locateOnnxDirInBundle ( )
env = try ORTEnv ( loggingLevel : . warning )
textToSpeech = try loadTextToSpeech ( bundleOnnxDir , false , env )
sampleRate = textToSpeech . sampleRate
}
2025-11-26 11:11:57 +09:00
func synthesize ( text : String , nfe : Int , voice : Voice ) async throws -> URL {
// L o a d 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 styleURL = try Self . locateVoiceStyleURL ( voice : voice )
let style = try loadVoiceStyle ( [ styleURL . path ] , verbose : false )
2025-11-23 15:54:59 +09:00
// 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 )
2025-11-26 11:11:57 +09:00
return tmpURL
2025-11-23 15:54:59 +09:00
}
// 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. " ]
)
}
}