Compare commits
5 Commits
pr-18
...
ios/issue2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba44107ee | ||
|
|
7941a24074 | ||
|
|
833dc1858b | ||
|
|
0ea52c526e | ||
|
|
0fd9b11372 |
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user