2025-11-23 15:54:59 +09:00
import Foundation
import OnnxRuntimeBindings
final class TTSService {
enum Voice { case male , female }
2026-01-06 17:15:20 +09:00
enum Language : String , CaseIterable {
case en = " en "
case ko = " ko "
2026-05-06 23:09:06 +02:00
case ja = " ja "
case ar = " ar "
case bg = " bg "
case cs = " cs "
case da = " da "
case de = " de "
case el = " el "
2026-01-06 17:15:20 +09:00
case es = " es "
2026-05-06 23:09:06 +02:00
case et = " et "
case fi = " fi "
2026-01-06 17:15:20 +09:00
case fr = " fr "
2026-05-06 23:09:06 +02:00
case hi = " hi "
case hr = " hr "
case hu = " hu "
case id = " id "
case it = " it "
case lt = " lt "
case lv = " lv "
case nl = " nl "
case pl = " pl "
case pt = " pt "
case ro = " ro "
case ru = " ru "
case sk = " sk "
case sl = " sl "
case sv = " sv "
case tr = " tr "
case uk = " uk "
case vi = " vi "
2026-05-15 23:22:42 +09:00
case na = " na "
2026-01-06 17:15:20 +09:00
var displayName : String {
switch self {
case . en : return " English "
case . ko : return " 한국어 "
2026-05-06 23:09:06 +02:00
case . ja : return " 日本語 "
case . ar : return " العربية "
case . bg : return " Bulgarian "
case . cs : return " Czech "
case . da : return " Danish "
case . de : return " Deutsch "
case . el : return " Greek "
2026-01-06 17:15:20 +09:00
case . es : return " Español "
2026-05-06 23:09:06 +02:00
case . et : return " Estonian "
case . fi : return " Finnish "
2026-01-06 17:15:20 +09:00
case . fr : return " Français "
2026-05-06 23:09:06 +02:00
case . hi : return " Hindi "
case . hr : return " Croatian "
case . hu : return " Hungarian "
case . id : return " Indonesian "
case . it : return " Italian "
case . lt : return " Lithuanian "
case . lv : return " Latvian "
case . nl : return " Dutch "
case . pl : return " Polish "
case . pt : return " Português "
case . ro : return " Romanian "
case . ru : return " Russian "
case . sk : return " Slovak "
case . sl : return " Slovenian "
case . sv : return " Swedish "
case . tr : return " Turkish "
case . uk : return " Ukrainian "
case . vi : return " Vietnamese "
2026-05-15 23:22:42 +09:00
case . na : return " Auto (language-agnostic) "
2026-01-06 17:15:20 +09:00
}
}
}
2025-11-23 15:54:59 +09:00
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
}
2026-01-06 17:15:20 +09:00
func synthesize ( text : String , nfe : Int , voice : Voice , language : Language ) async throws -> URL {
2025-11-26 11:11:57 +09:00
// 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
2026-01-06 17:15:20 +09:00
let ( wav , duration ) = try textToSpeech . call ( text , language . rawValue , style , nfe )
2025-11-26 10:55:36 +09:00
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. " ]
)
}
}