5 Commits

Author SHA1 Message Date
haeon
9ba44107ee minor refactoring 2025-11-26 11:11:57 +09:00
haeon
7941a24074 fix array type 2025-11-26 10:55:36 +09:00
ANLGBOY
833dc1858b remove comment leftover (Fixes #24) 2025-11-26 02:05:07 +09:00
ANLGBOY
0ea52c526e fix curly quotes errors (Fixes #23) 2025-11-25 07:58:21 +09:00
Hyeongju Kim
0fd9b11372 Merge pull request #20 from supertone-inc/pr-18 2025-11-24 12:05:36 +09:00
5 changed files with 39 additions and 89 deletions

View File

@@ -77,10 +77,10 @@ std::string UnicodeProcessor::preprocessText(const std::string& text) {
{"", "-"}, // em dash {"", "-"}, // em dash
{"¯", " "}, // macron {"¯", " "}, // macron
{"_", " "}, // underscore {"_", " "}, // underscore
{""", "\""}, // left double quote (U+201C) { u8"\u201C", "\"" }, // left double quote
{""", "\""}, // right double quote (U+201D) { u8"\u201D", "\"" }, // right double quote
{"'", "'"}, // left single quote (U+2018) { u8"\u2018", "'" }, // left single quote
{"'", "'"}, // right single quote (U+2019) { u8"\u2019", "'" }, // right single quote
{"´", "'"}, // acute accent {"´", "'"}, // acute accent
{"`", "'"}, // grave accent {"`", "'"}, // grave accent
{"[", " "}, // left bracket {"[", " "}, // left bracket
@@ -175,9 +175,9 @@ std::string UnicodeProcessor::preprocessText(const std::string& text) {
last_three == "" || last_three == "" || last_three == "" || last_three == "" ||
last_three == "" || last_three == "" || last_three == "" || last_three == "" ||
last_three == "" || last_three == "" || last_three == "" || last_three == "" ||
last_three == "»" || last_three == """ || last_three == "»" || last_three == u8"\u201C" ||
last_three == """ || last_three == "'" || last_three == u8"\u201D" || last_three == u8"\u2018" ||
last_three == "'") { last_three == u8"\u2019") {
ends_with_punct = true; ends_with_punct = true;
} }
} }

View File

@@ -16,24 +16,16 @@ struct ContentView: View {
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
.foregroundColor(.primary) .foregroundColor(.primary)
ZStack(alignment: .topLeading) { TextEditor(text: $vm.text)
if vm.text.isEmpty { .frame(minHeight: 120, maxHeight: 180)
Text("Type text to synthesize") .padding(8)
.foregroundColor(.secondary) .background(Color(.secondarySystemBackground))
.padding(.horizontal, 14) .cornerRadius(12)
.padding(.vertical, 12) .overlay(
} RoundedRectangle(cornerRadius: 12)
TextEditor(text: $vm.text) .stroke(Color.secondary.opacity(0.3), lineWidth: 1)
.frame(minHeight: 120, maxHeight: 180) )
.padding(8) .padding(.horizontal)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
}
.padding(.horizontal)
HStack(spacing: 12) { HStack(spacing: 12) {
Text("NFE") Text("NFE")
@@ -92,7 +84,3 @@ struct ContentView: View {
.onAppear { vm.startup() } .onAppear { vm.startup() }
} }
} }
#Preview {
ContentView()
}

View File

@@ -4,25 +4,11 @@ import OnnxRuntimeBindings
final class TTSService { final class TTSService {
enum Voice { case male, female } enum Voice { case male, female }
struct Settings {
var nTest: Int = 1
}
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 env: ORTEnv
private let textToSpeech: TextToSpeech private let textToSpeech: TextToSpeech
private let bundleOnnxDir: String private let bundleOnnxDir: String
private let sampleRate: Int private let sampleRate: Int
// Cached style per voice (precomputed at startup or on first use)
private var cachedStyle: [Voice: Style] = [:]
init() throws { init() throws {
bundleOnnxDir = try Self.locateOnnxDirInBundle() bundleOnnxDir = try Self.locateOnnxDirInBundle()
env = try ORTEnv(loggingLevel: .warning) env = try ORTEnv(loggingLevel: .warning)
@@ -30,50 +16,21 @@ final class TTSService {
sampleRate = textToSpeech.sampleRate sampleRate = textToSpeech.sampleRate
} }
// Public warmup: precompute styles and run a quick generation to warm models func synthesize(text: String, nfe: Int, voice: Voice) async throws -> URL {
func warmup(nfe: Int = 1) async { // Load style for the selected voice
do { try precomputeStyle(for: .male) } catch { print("Warmup style (M) error: \(error)") } let styleURL = try Self.locateVoiceStyleURL(voice: voice)
do { try precomputeStyle(for: .female) } catch { print("Warmup style (F) error: \(error)") } let style = try loadVoiceStyle([styleURL.path], verbose: false)
// Run a tiny synthesis to JIT/warm up kernels; discard file
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)")
}
}
func synthesize(text: String, nfe: Int, voice: Voice, settings: Settings = Settings()) async throws -> SynthesisResult {
let tic = Date()
// 1) Get or compute style for the selected voice
let style = try getStyle(voice: voice)
// 2) Synthesize via packed TextToSpeech component // 2) Synthesize via packed TextToSpeech component
let (wav, duration) = try textToSpeech.call([text], style, nfe) let (wav, duration) = try textToSpeech.call(text, style, nfe)
let audioSeconds = Double(duration[0]) let audioSeconds = Double(duration)
let wavLenSample = min(Int(Double(sampleRate) * audioSeconds), wav.count) let wavLenSample = min(Int(Double(sampleRate) * audioSeconds), wav.count)
let wavOut = Array(wav[0..<wavLenSample]) let wavOut = Array(wav[0..<wavLenSample])
let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("supertonic_tts_\(UUID().uuidString).wav") let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("supertonic_tts_\(UUID().uuidString).wav")
try writeWavFile(tmpURL.path, wavOut, sampleRate) try writeWavFile(tmpURL.path, wavOut, sampleRate)
let elapsed = Date().timeIntervalSince(tic) return tmpURL
return SynthesisResult(url: tmpURL, elapsedSeconds: elapsed, audioSeconds: audioSeconds)
}
// MARK: - Style helpers
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: - Resource location helpers // MARK: - Resource location helpers

View File

@@ -10,7 +10,6 @@ final class TTSViewModel: ObservableObject {
@Published var isPlaying: Bool = false @Published var isPlaying: Bool = false
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var audioURL: URL? @Published var audioURL: URL?
@Published var elapsedSeconds: Double? @Published var elapsedSeconds: Double?
@Published var audioSeconds: Double? @Published var audioSeconds: Double?
@@ -19,14 +18,12 @@ final class TTSViewModel: ObservableObject {
var rtfText: String? { var rtfText: String? {
guard let e = elapsedSeconds, let a = audioSeconds, a > 0 else { return nil } guard let e = elapsedSeconds, let a = audioSeconds, a > 0 else { return nil }
let rtf = e / a return String(format: "RTF %.2fx · %.2fs / %.2fs", e / a, e, a)
return String(format: "RTF %.2fx · %.2fs / %.2fs", rtf, e, a)
} }
func startup() { func startup() {
do { do {
service = try TTSService() service = try TTSService()
Task { await self.service?.warmup(nfe: 5) }
} catch { } catch {
errorMessage = "Failed to init TTS: \(error.localizedDescription)" errorMessage = "Failed to init TTS: \(error.localizedDescription)"
} }
@@ -40,15 +37,18 @@ final class TTSViewModel: ObservableObject {
elapsedSeconds = nil elapsedSeconds = nil
audioSeconds = nil audioSeconds = nil
Task { Task {
let tic = Date()
do { do {
let result = try await service.synthesize(text: text, nfe: Int(nfe), voice: voice) let url = try await service.synthesize(text: text, nfe: Int(nfe), voice: voice)
let elapsed = Date().timeIntervalSince(tic)
let audio = audioDuration(at: url)
await MainActor.run { await MainActor.run {
self.audioURL = result.url self.audioURL = url
self.elapsedSeconds = result.elapsedSeconds self.elapsedSeconds = elapsed
self.audioSeconds = result.audioSeconds self.audioSeconds = audio
self.isGenerating = false self.isGenerating = false
self.play(url: url)
} }
self.play(url: result.url)
} catch { } catch {
await MainActor.run { await MainActor.run {
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
@@ -73,4 +73,9 @@ final class TTSViewModel: ObservableObject {
} }
isPlaying = true isPlaying = true
} }
private func audioDuration(at url: URL) -> Double? {
guard let file = try? AVAudioFile(forReading: url) else { return nil }
return Double(file.length) / file.fileFormat.sampleRate
}
} }

View File

@@ -12,7 +12,7 @@ targets:
deploymentTarget: "15.0" deploymentTarget: "15.0"
sources: sources:
- path: . - path: .
- path: ../../swift/Sources/Helper.swift <<- 여기 - path: ../../swift/Sources/Helper.swift
type: file type: file
resources: resources:
- path: onnx - path: onnx