Merge pull request #1165 from omnivore-app/feat/streaming-audio

WIP: iOS streaming audio
This commit is contained in:
Jackson Harper
2022-09-13 20:18:57 +08:00
committed by GitHub
19 changed files with 1214 additions and 600 deletions

View File

@ -12,13 +12,13 @@ public final class Services {
public let authenticator: Authenticator
public let dataService: DataService
public let audioSession: AudioSession
public let audioController: AudioController
public init(appEnvironment: AppEnvironment = PublicValet.storedAppEnvironment ?? .initialAppEnvironment) {
let networker = Networker(appEnvironment: appEnvironment)
self.authenticator = Authenticator(networker: networker)
self.dataService = DataService(appEnvironment: appEnvironment, networker: networker)
self.audioSession = AudioSession(appEnvironment: appEnvironment, networker: networker)
self.audioController = AudioController(appEnvironment: appEnvironment, networker: networker)
}
}

View File

@ -12,7 +12,7 @@ import SwiftUI
import Views
public struct MiniPlayer: View {
@EnvironmentObject var audioSession: AudioSession
@EnvironmentObject var audioController: AudioController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
private let presentingView: AnyView
@ -30,26 +30,42 @@ public struct MiniPlayer: View {
}
var isPresented: Bool {
audioSession.item != nil && audioSession.state != .stopped
audioController.itemAudioProperties != nil && audioController.state != .stopped
}
var playPauseButtonImage: String {
switch audioController.state {
case .playing:
return "pause.circle"
case .paused:
return "play.circle"
case .reachedEnd:
return "gobackward"
default:
return ""
}
}
var playPauseButtonItem: some View {
if let item = audioSession.item, audioSession.isLoadingItem(item: item) {
if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) {
return AnyView(ProgressView())
} else {
return AnyView(Button(
action: {
switch audioSession.state {
switch audioController.state {
case .playing:
_ = audioSession.pause()
audioController.pause()
case .paused:
_ = audioSession.unpause()
audioController.unpause()
case .reachedEnd:
audioController.seek(to: 0.0)
audioController.unpause()
default:
break
}
},
label: {
Image(systemName: audioSession.state == .playing ? "pause.circle" : "play.circle")
Image(systemName: playPauseButtonImage)
.font(expanded ? .system(size: 64.0, weight: .thin) : .appTitleTwo)
}
))
@ -59,7 +75,7 @@ public struct MiniPlayer: View {
var stopButton: some View {
Button(
action: {
audioSession.stop()
audioController.stop()
},
label: {
Image(systemName: "xmark")
@ -68,25 +84,25 @@ public struct MiniPlayer: View {
)
}
var shareButton: some View {
Button(
action: {
let shareActivity = UIActivityViewController(activityItems: [self.audioSession.localAudioUrl], applicationActivities: nil)
if let vc = UIApplication.shared.windows.first?.rootViewController {
shareActivity.popoverPresentationController?.sourceView = vc.view
// Setup share activity position on screen on bottom center
shareActivity.popoverPresentationController?.sourceRect = CGRect(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height, width: 0, height: 0)
shareActivity.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.down
vc.present(shareActivity, animated: true, completion: nil)
}
},
label: {
Image(systemName: "square.and.arrow.up")
.font(.appCallout)
.tint(.appGrayText)
}
)
}
// var shareButton: some View {
// Button(
// action: {
// let shareActivity = UIActivityViewController(activityItems: [self.audioSession.localAudioUrl], applicationActivities: nil)
// if let vc = UIApplication.shared.windows.first?.rootViewController {
// shareActivity.popoverPresentationController?.sourceView = vc.view
// // Setup share activity position on screen on bottom center
// shareActivity.popoverPresentationController?.sourceRect = CGRect(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height, width: 0, height: 0)
// shareActivity.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.down
// vc.present(shareActivity, animated: true, completion: nil)
// }
// },
// label: {
// Image(systemName: "square.and.arrow.up")
// .font(.appCallout)
// .tint(.appGrayText)
// }
// )
// }
var closeButton: some View {
Button(
@ -104,8 +120,8 @@ public struct MiniPlayer: View {
}
func viewArticle() {
if let item = audioSession.item {
NSNotification.pushReaderItem(objectID: item.objectID)
if let objectID = audioController.itemAudioProperties?.objectID {
NSNotification.pushReaderItem(objectID: objectID)
withAnimation(.easeIn(duration: 0.1)) {
expanded = false
}
@ -113,18 +129,18 @@ public struct MiniPlayer: View {
}
// swiftlint:disable:next function_body_length
func playerContent(_ item: LinkedItem) -> some View {
func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View {
GeometryReader { geom in
VStack {
if expanded {
ZStack {
closeButton
.padding(.top, 8)
.padding(.top, 24)
.frame(maxWidth: .infinity, alignment: .leading)
shareButton
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .trailing)
// shareButton
// .padding(.top, 8)
// .frame(maxWidth: .infinity, alignment: .trailing)
Capsule()
.fill(.gray)
@ -140,7 +156,7 @@ public struct MiniPlayer: View {
let maxSize = 2 * (min(geom.size.width, geom.size.height) / 3)
let dim = expanded ? maxSize : 64
AsyncImage(url: item.imageURL) { image in
AsyncImage(url: itemAudioProperties.imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
@ -153,7 +169,7 @@ public struct MiniPlayer: View {
}
if !expanded {
Text(item.unwrappedTitle)
Text(itemAudioProperties.title)
.font(expanded ? .appTitle : .appCallout)
.lineSpacing(1.25)
.foregroundColor(.appGrayTextContrast)
@ -172,7 +188,7 @@ public struct MiniPlayer: View {
Spacer()
if expanded {
Text(item.unwrappedTitle)
Text(itemAudioProperties.title)
.lineLimit(1)
.font(expanded ? .appTitle : .appCallout)
.lineSpacing(1.25)
@ -185,7 +201,7 @@ public struct MiniPlayer: View {
HStack {
Spacer()
if let author = item.author {
if let author = itemAudioProperties.author {
Text(author)
.lineLimit(1)
.font(.appCallout)
@ -193,14 +209,14 @@ public struct MiniPlayer: View {
.foregroundColor(.appGrayText)
.frame(alignment: .trailing)
}
if item.author != nil, item.siteName != nil {
if itemAudioProperties.author != nil, itemAudioProperties.siteName != nil {
Text("")
.font(.appCallout)
.lineSpacing(1.25)
.foregroundColor(.appGrayText)
}
if let site = item.siteName {
Text(site)
if let siteName = itemAudioProperties.siteName {
Text(siteName)
.lineLimit(1)
.font(.appCallout)
.lineSpacing(1.25)
@ -210,13 +226,13 @@ public struct MiniPlayer: View {
Spacer()
}
Slider(value: $audioSession.timeElapsed,
in: 0 ... self.audioSession.duration,
Slider(value: $audioController.timeElapsed,
in: 0 ... self.audioController.duration,
onEditingChanged: { scrubStarted in
if scrubStarted {
self.audioSession.scrubState = .scrubStarted
self.audioController.scrubState = .scrubStarted
} else {
self.audioSession.scrubState = .scrubEnded(self.audioSession.timeElapsed)
self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed)
}
})
.accentColor(.appCtaYellow)
@ -237,25 +253,26 @@ public struct MiniPlayer: View {
}
HStack {
Text(audioSession.timeElapsedString ?? "0:00")
Text(audioController.timeElapsedString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
Spacer()
Text(audioSession.durationString ?? "0:00")
Text(audioController.durationString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
}
HStack {
Menu {
Button("1.0×", action: {})
Button("1.2×", action: {})
Button("1.5×", action: {})
Button("1.7×", action: {})
Button("2.0×", action: {})
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
} label: {
VStack {
Text("1.0×")
Text(String(format: "%.1f×", audioController.playbackRate))
.font(.appCallout)
.lineLimit(0)
}
@ -264,7 +281,7 @@ public struct MiniPlayer: View {
.padding(8)
Button(
action: { self.audioSession.skipBackwards(seconds: 30) },
action: { self.audioController.skipBackwards(seconds: 30) },
label: {
Image(systemName: "gobackward.30")
.font(.appTitleTwo)
@ -276,7 +293,7 @@ public struct MiniPlayer: View {
.padding(32)
Button(
action: { self.audioSession.skipForward(seconds: 30) },
action: { self.audioController.skipForward(seconds: 30) },
label: {
Image(systemName: "goforward.30")
.font(.appTitleTwo)
@ -312,13 +329,29 @@ public struct MiniPlayer: View {
}
}
func playbackRateButton(rate: Double, title: String, selected: Bool) -> some View {
Button(action: {
audioController.playbackRate = rate
}) {
HStack {
Text(title)
Spacer()
if selected {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
}
public var body: some View {
ZStack(alignment: .center) {
presentingView
VStack {
Spacer()
if let item = self.audioSession.item, isPresented {
playerContent(item)
Spacer(minLength: 0)
if let itemAudioProperties = self.audioController.itemAudioProperties, isPresented {
playerContent(itemAudioProperties)
.offset(y: offset)
.frame(maxHeight: expanded ? .infinity : 88)
.tint(.appGrayTextContrast)
@ -333,9 +366,21 @@ public struct MiniPlayer: View {
NavigationView {
VStack {
List {
ForEach(["Jenny", "Guy"], id: \.self) { name in
Button(action: {}) {
Text(name)
ForEach(audioController.voiceList ?? [], id: \.key.self) { voice in
Button(action: {
audioController.currentVoice = voice.key
self.showVoiceSheet = false
}) {
HStack {
Text(voice.name)
Spacer()
if voice.selected {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
}
@ -344,7 +389,7 @@ public struct MiniPlayer: View {
.listStyle(.plain)
Spacer()
}
.navigationBarTitle("Change Voice")
.navigationBarTitle("Voice")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }) {
Image(systemName: "chevron.backward")

View File

@ -5,7 +5,7 @@ import Views
struct FeedCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@EnvironmentObject var audioController: AudioController
let item: LinkedItem
@ -24,7 +24,7 @@ struct FeedCardNavigationLink: View {
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioSession: audioSession) }
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
}
FeedCard(item: item)
}
@ -34,7 +34,7 @@ struct FeedCardNavigationLink: View {
struct GridCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@EnvironmentObject var audioController: AudioController
@State private var scale = 1.0
@ -63,7 +63,7 @@ struct GridCardNavigationLink: View {
withAnimation { tapAction() }
})
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioSession: audioSession) }
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
}
}
.aspectRatio(1.8, contentMode: .fill)

View File

@ -12,13 +12,13 @@ import Views
struct HomeFeedContainerView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@EnvironmentObject var audioController: AudioController
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = false
@ObservedObject var viewModel: HomeFeedViewModel
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, audioSession: audioSession, isRefresh: isRefresh) }
Task { await viewModel.loadItems(dataService: dataService, audioController: audioController, isRefresh: isRefresh) }
}
var body: some View {
@ -144,35 +144,6 @@ import Views
}
}
}
.sheet(isPresented: $viewModel.showAudioInfoAlert) {
VStack {
Text("Welcome to the Omnivore text to speech beta.")
.font(.appTitle)
Spacer()
Text(
"""
This build introduces offline text to speech files. Normally these files will\
be downloaded in the background and made available offline.
During the beta these files can be manually downloaded by long pressing on an item and\
choosing Download Audio, or by tapping the play button. When you first tap the\
play button, the audio will be generated and downloaded. This can take some time.
Future versions will do this in the background.
""")
Text("")
Spacer()
Button(
action: { viewModel.dismissAudioInfoAlert() },
label: { Text("Dismiss").frame(maxWidth: .infinity) }
)
.buttonStyle(RoundedRectButtonStyle())
}.padding(24)
}
.task {
if viewModel.items.isEmpty {
loadItems(isRefresh: true)
@ -259,7 +230,7 @@ import Views
struct HomeFeedListView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@EnvironmentObject var audioController: AudioController
@Binding var prefersListLayout: Bool
@ -322,7 +293,7 @@ import Views
}
}
Button(
action: { viewModel.downloadAudio(audioSession: audioSession, item: item) },
action: { viewModel.downloadAudio(audioController: audioController, item: item) },
label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") }
)
}
@ -391,7 +362,7 @@ import Views
struct HomeFeedGridView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@EnvironmentObject var audioController: AudioController
@State private var itemToRemove: LinkedItem?
@State private var confirmationShown = false
@ -411,12 +382,12 @@ import Views
case .editTitle:
viewModel.itemUnderTitleEdit = item
case .downloadAudio:
viewModel.downloadAudio(audioSession: audioSession, item: item)
viewModel.downloadAudio(audioController: audioController, item: item)
}
}
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, audioSession: audioSession, isRefresh: isRefresh) }
Task { await viewModel.loadItems(dataService: dataService, audioController: audioController, isRefresh: isRefresh) }
}
var body: some View {

View File

@ -29,8 +29,6 @@ import Views
@Published var selectedItem: LinkedItem?
@Published var linkIsActive = false
@AppStorage(UserDefaultKey.audioInfoAlertShown.rawValue) var showAudioInfoAlert = false
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue
@AppStorage(UserDefaultKey.lastItemSyncTime.rawValue) var lastItemSyncTime = DateFormatter.formatterISO8601.string(
@ -49,14 +47,14 @@ import Views
var searchIdx = 0
var receivedIdx = 0
func itemAppeared(item: LinkedItem, dataService: DataService, audioSession: AudioSession) async {
func itemAppeared(item: LinkedItem, dataService: DataService, audioController: AudioController) async {
if isLoading { return }
let itemIndex = items.firstIndex(where: { $0.id == item.id })
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
// Check if user has scrolled to the last five items in the list
if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 {
await loadItems(dataService: dataService, audioSession: audioSession, isRefresh: false)
await loadItems(dataService: dataService, audioController: audioController, isRefresh: false)
}
}
@ -64,7 +62,7 @@ import Views
items.insert(item, at: 0)
}
func loadItems(dataService: DataService, audioSession: AudioSession, isRefresh: Bool) async {
func loadItems(dataService: DataService, audioController: AudioController, isRefresh: Bool) async {
let syncStartTime = Date()
let thisSearchIdx = searchIdx
searchIdx += 1
@ -137,7 +135,7 @@ import Views
// This happens because when an article is saved, we check if the user has a recent
// listen. If they do, we will automatically transcribe their message.
if let first = newItems.first?.id {
_ = await audioSession.preload(itemIDs: [first])
_ = await audioController.preload(itemIDs: [first])
}
}
} else {
@ -148,15 +146,10 @@ import Views
showLoadingBar = false
}
func dismissAudioInfoAlert() {
UserDefaults.standard.set(true, forKey: UserDefaultKey.audioInfoAlertShown.rawValue)
showAudioInfoAlert = false
}
func downloadAudio(audioSession: AudioSession, item: LinkedItem) {
func downloadAudio(audioController: AudioController, item: LinkedItem) {
Snackbar.show(message: "Downloading Offline Audio")
Task {
let downloaded = await audioSession.preload(itemIDs: [item.unwrappedID])
let downloaded = await audioController.downloadForOffline(itemID: item.unwrappedID)
Snackbar.show(message: downloaded ? "Audio file downloaded" : "Error downloading audio")
}
}

View File

@ -27,7 +27,7 @@ public struct RootView: View {
InnerRootView(viewModel: viewModel)
.environmentObject(viewModel.services.authenticator)
.environmentObject(viewModel.services.dataService)
.environmentObject(viewModel.services.audioSession)
.environmentObject(viewModel.services.audioController)
.environment(\.managedObjectContext, viewModel.services.dataService.viewContext)
.onChange(of: scenePhase) { phase in
if phase == .background {

View File

@ -24,7 +24,7 @@ struct WebReaderContainerView: View {
@State var annotation = String()
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@EnvironmentObject var audioController: AudioController
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@StateObject var viewModel = WebReaderViewModel()
@ -57,32 +57,32 @@ struct WebReaderContainerView: View {
}
var audioNavbarItem: some View {
if audioSession.isLoadingItem(item: item) {
if audioController.isLoadingItem(itemID: item.unwrappedID) {
return AnyView(ProgressView()
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio))
} else {
return AnyView(Button(
action: {
switch audioSession.state {
switch audioController.state {
case .playing:
if audioSession.item == self.item {
audioSession.pause()
if audioController.itemAudioProperties?.itemID == self.item.unwrappedID {
audioController.pause()
return
}
fallthrough
case .paused:
if audioSession.item == self.item {
audioSession.unpause()
if audioController.itemAudioProperties?.itemID == self.item.unwrappedID {
audioController.unpause()
return
}
fallthrough
default:
audioSession.play(item: self.item)
audioController.play(itemAudioProperties: item.audioProperties)
}
},
label: {
Image(systemName: audioSession.isPlayingItem(item: item) ? "pause.circle" : "play.circle")
Image(systemName: audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle")
.font(.appTitleTwo)
}
)

View File

@ -33,11 +33,7 @@ struct WelcomeView: View {
var headlineText: some View {
Group {
if horizontalSizeClass == .compact {
Text("Everything you read. Safe, organized, and easy to share.")
} else {
Text("Everything you read. Safe,\norganized, and easy to share.")
}
Text("Never miss a great read.")
}
.font(.appLargeTitle)
}

View File

@ -25,14 +25,13 @@ private let devBaseURL = "https://api-dev.omnivore.app"
private let demoBaseURL = "https://api-demo.omnivore.app"
private let prodBaseURL = "https://api-prod.omnivore.app"
private let demoTtsURL = "https://tts-demo.omnivore.app"
private let prodTtsURL = "https://tts-prod.omnivore.app"
private let devWebURL = "https://web-dev.omnivore.app"
private let demoWebURL = "https://demo.omnivore.app"
private let prodWebURL = "https://omnivore.app"
private let devHighlightsServerURL = "https://highlights-dev.omnivore.app"
private let demoHighlightsServerURL = "https://highlights-demo.omnivore.app"
private let prodHighlightsServerURL = "https://highlights.omnivore.app"
public extension AppEnvironment {
var graphqlPath: String {
"\(serverBaseURL.absoluteString)/api/graphql"
@ -63,4 +62,17 @@ public extension AppEnvironment {
return URL(string: "http://localhost:3000")!
}
}
var ttsBaseURL: URL {
switch self {
case .dev:
return URL(string: "notimplemented")!
case .demo:
return URL(string: demoTtsURL)!
case .prod:
return URL(string: prodTtsURL)!
case .test, .local:
return URL(string: "http://localhost:4000")!
}
}
}

View File

@ -12,6 +12,15 @@ public struct LinkedItemQueryResult {
}
}
public struct LinkedItemAudioProperties {
public let itemID: String
public let objectID: NSManagedObjectID
public let title: String
public let author: String?
public let siteName: String?
public let imageURL: URL?
}
// Internal model used for parsing a push notification object only
public struct JSONArticle: Decodable {
public let id: String
@ -84,6 +93,17 @@ public extension LinkedItem {
return String(data: JSON, encoding: .utf8) ?? "[]"
}
var audioProperties: LinkedItemAudioProperties {
LinkedItemAudioProperties(
itemID: unwrappedID,
objectID: objectID,
title: unwrappedTitle,
author: author,
siteName: siteName,
imageURL: imageURL
)
}
static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LinkedItem? {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(

View File

@ -0,0 +1,766 @@
//
// AudioController.swift
//
//
// Created by Jackson Harper on 8/15/22.
//
import AVFoundation
import CryptoKit
import Foundation
import MediaPlayer
import Models
import SwiftUI
import Utils
public enum AudioControllerState {
case stopped
case paused
case loading
case playing
case reachedEnd
}
public enum PlayerScrubState {
case reset
case scrubStarted
case scrubEnded(TimeInterval)
}
enum DownloadPriority: String {
case low
case high
}
struct VoicePair {
let firstKey: String
let secondKey: String
let firstName: String
let secondName: String
}
let VOICES = [
VoicePair(firstKey: "en-US-JennyNeural", secondKey: "en-US-BrandonNeural", firstName: "Jenny (USA)", secondName: "Brandon (USA)"),
VoicePair(firstKey: "en-US-CoraNeural", secondKey: "en-US-ChristopherNeural", firstName: "Cora (USA)", secondName: "Christopher (USA)"),
VoicePair(firstKey: "en-US-ElizabethNeural", secondKey: "en-US-EricNeural", firstName: "Elizabeth (USA)", secondName: "Eric (USA)"),
VoicePair(firstKey: "en-CA-ClaraNeural", secondKey: "en-CA-LiamNeural", firstName: "Clara (Canada)", secondName: "Liam (Canada)"),
VoicePair(firstKey: "en-GB-LibbyNeural", secondKey: "en-GB-EthanNeural", firstName: "Libby (UK)", secondName: "Ethan (UK)"),
VoicePair(firstKey: "en-AU-NatashaNeural", secondKey: "en-AU-WilliamNeural", firstName: "Natasha (Australia)", secondName: "William (Australia)"),
VoicePair(firstKey: "en-IN-NeerjaNeural", secondKey: "en-IN-PrabhatNeural", firstName: "Neerja (India)", secondName: "Prabhat (India)"),
VoicePair(firstKey: "en-SG-LunaNeural", secondKey: "en-SG-WayneNeural", firstName: "Luna (Singapore)", secondName: "Wayne (Singapore)")
]
class SpeechPlayerItem: AVPlayerItem {
let session: AudioController
let speechItem: SpeechItem
let completed: () -> Void
var observer: Any?
init(session: AudioController, speechItem: SpeechItem, url: URL, completed: @escaping () -> Void) {
self.session = session
self.speechItem = speechItem
self.completed = completed
let asset = AVAsset(url: url)
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
session.updateDuration(forItem: speechItem, newDuration: CMTimeGetSeconds(asset.duration))
self.observer = observe(\.status, options: [.new]) { item, _ in
item.session.updateDuration(forItem: item.speechItem, newDuration: CMTimeGetSeconds(item.duration))
}
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self, queue: OperationQueue.main) { _ in
self.completed()
}
}
}
// swiftlint:disable all
public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published public var state: AudioControllerState = .stopped
@Published public var itemAudioProperties: LinkedItemAudioProperties?
@Published public var timeElapsed: TimeInterval = 0
@Published public var duration: TimeInterval = 0
@Published public var timeElapsedString: String?
@Published public var durationString: String?
@Published public var voiceList: [(name: String, key: String, selected: Bool)]?
let appEnvironment: AppEnvironment
let networker: Networker
var timer: Timer?
var player: AVQueuePlayer?
var document: SpeechDocument?
var synthesizer: SpeechSynthesizer?
var durations: [Double]?
var playbackTask: Task<Void, Error>?
public init(appEnvironment: AppEnvironment, networker: Networker) {
self.appEnvironment = appEnvironment
self.networker = networker
super.init()
self.voiceList = generateVoiceList()
}
public func play(itemAudioProperties: LinkedItemAudioProperties) {
stop()
self.itemAudioProperties = itemAudioProperties
startAudio()
}
public func stop() {
player?.pause()
timer?.invalidate()
playbackTask?.cancel()
clearNowPlayingInfo()
player?.replaceCurrentItem(with: nil)
player?.removeAllItems()
timer = nil
player = nil
synthesizer = nil
itemAudioProperties = nil
state = .stopped
timeElapsed = 0
duration = 1
durations = nil
}
public func generateVoiceList() -> [(name: String, key: String, selected: Bool)] {
VOICES.flatMap { voicePair in
[
(name: voicePair.firstName, key: voicePair.firstKey, selected: voicePair.firstKey == currentVoice),
(name: voicePair.secondName, key: voicePair.secondKey, selected: voicePair.secondKey == currentVoice)
]
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
public func preload(itemIDs: [String], retryCount _: Int = 0) async -> Bool {
for itemID in itemIDs {
print("preloading speech file: ", itemID)
_ = try? await downloadSpeechFile(itemID: itemID, priority: .low)
}
return true
}
public func downloadForOffline(itemID: String) async -> Bool {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document)
for await _ in synthesizer.fetch(from: 0) {}
return true
}
return false
}
public var scrubState: PlayerScrubState = .reset {
didSet {
switch scrubState {
case .reset:
return
case .scrubStarted:
return
case let .scrubEnded(seekTime):
seek(to: seekTime)
}
}
}
func updateDuration(forItem item: SpeechItem, newDuration: TimeInterval) {
if let durations = self.durations, item.audioIdx < durations.count {
self.durations?[item.audioIdx] = (newDuration / playbackRate)
}
}
public func seek(to: TimeInterval) {
let position = max(0, to)
// First find the item that this interval is within
// Not the most effecient, but these lists should be less than 500 items
var sum = 0.0
var foundIdx: Int?
for (idx, duration) in (durations ?? []).enumerated() {
if sum + duration > position {
foundIdx = idx
break
}
sum += duration
}
if let foundIdx = foundIdx {
// Now figure out how far into this segment we need to seek to
let before = durationBefore(playerIndex: foundIdx)
let remainder = position - before
// if the foundIdx happens to be the current item, we just set the position
if let playerItem = player?.currentItem as? SpeechPlayerItem {
if playerItem.speechItem.audioIdx == foundIdx {
playerItem.seek(to: CMTimeMakeWithSeconds(remainder, preferredTimescale: 600), completionHandler: nil)
return
}
}
// Move the playback to the found index, we should also seek a bit
// within this index, but this is probably accurate enough for now.
player?.removeAllItems()
synthesizeFrom(start: foundIdx, playWhenReady: state == .playing, atOffset: remainder)
return
} else {
// There was no foundIdx, so we are probably trying to seek past the end, so
// just seek to the last possible duration.
if let durations = self.durations, let last = durations.last {
player?.removeAllItems()
synthesizeFrom(start: durations.count - 1, playWhenReady: state == .playing, atOffset: last)
}
}
}
@AppStorage(UserDefaultKey.textToSpeechPlaybackRate.rawValue) public var playbackRate = 1.0 {
didSet {
updateDurations(oldPlayback: oldValue, newPlayback: playbackRate)
unpause()
fireTimer()
}
}
@AppStorage(UserDefaultKey.textToSpeechCurrentVoice.rawValue) public var currentVoice = "en-US-JennyNeural" {
didSet {
voiceList = generateVoiceList()
var currentIdx = 0
var currentOffset = 0.0
if let player = self.player, let item = self.player?.currentItem as? SpeechPlayerItem {
currentIdx = item.speechItem.audioIdx
currentOffset = CMTimeGetSeconds(player.currentTime())
}
player?.removeAllItems()
downloadAndPlayFrom(currentIdx, currentOffset)
}
}
private func downloadAndPlayFrom(_ currentIdx: Int, _ currentOffset: Double) {
let desiredState = state
pause()
playbackTask?.cancel()
document = nil
synthesizer = nil
if let itemID = itemAudioProperties?.itemID {
Task {
self.document = try? await downloadSpeechFile(itemID: itemID, priority: .high)
DispatchQueue.main.async {
let synthesizer = SpeechSynthesizer(appEnvironment: self.appEnvironment, networker: self.networker, document: self.document!)
self.durations = synthesizer.estimatedDurations(forSpeed: self.playbackRate)
self.synthesizer = synthesizer
self.state = desiredState
self.synthesizeFrom(start: currentIdx, playWhenReady: self.state == .playing, atOffset: currentOffset)
}
}
}
}
public var secondaryVoice: String {
let pair = VOICES.first { $0.firstKey == currentVoice || $0.secondKey == currentVoice }
if let pair = pair {
if pair.firstKey == currentVoice {
return pair.secondKey
}
if pair.secondKey == currentVoice {
return pair.firstKey
}
}
return "en-US-EricNeural"
}
private func updateDurations(oldPlayback: Double, newPlayback: Double) {
if let oldDurations = durations {
durations = oldDurations.map { $0 * oldPlayback / newPlayback }
}
}
public func isLoadingItem(itemID: String) -> Bool {
if state == .reachedEnd {
return false
}
return
itemAudioProperties?.itemID == itemID &&
(state == .loading || player?.currentItem == nil || player?.currentItem?.status == .unknown)
}
public func isPlayingItem(itemID: String) -> Bool {
state == .playing && itemAudioProperties?.itemID == itemID
}
public func skipForward(seconds: Double) {
seek(to: timeElapsed + seconds)
}
public func skipBackwards(seconds: Double) {
seek(to: timeElapsed - seconds)
}
public func fileNameForAudioFile(_ itemID: String) -> String {
itemID + "-" + currentVoice + ".mp3"
}
public func pathForAudioDirectory(itemID: String) -> URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("audio-\(itemID)/")
}
public func pathForSpeechFile(itemID: String) -> URL {
pathForAudioDirectory(itemID: itemID)
.appendingPathComponent("speech-\(currentVoice).json")
}
public func startAudio() {
state = .loading
setupNotifications()
if let itemID = itemAudioProperties?.itemID {
Task {
self.document = try? await downloadSpeechFile(itemID: itemID, priority: .high)
DispatchQueue.main.async {
self.startStreamingAudio(itemID: itemID)
}
}
}
}
// swiftlint:disable all
private func startStreamingAudio(itemID _: String) {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
} catch {
print("error playing MP3 file", error)
// try? FileManager.default.removeItem(atPath: audioUrl.path)
state = .stopped
}
player = AVQueuePlayer(items: [])
let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document!)
durations = synthesizer.estimatedDurations(forSpeed: playbackRate)
self.synthesizer = synthesizer
synthesizeFrom(start: 0, playWhenReady: true)
}
func synthesizeFrom(start: Int, playWhenReady: Bool, atOffset: Double = 0.0) {
playbackTask = Task {
if let synthesizer = synthesizer {
for await speechItem in synthesizer.fetch(from: start) {
DispatchQueue.main.async {
let isLast = speechItem.audioIdx == synthesizer.document.utterances.count - 1
let item = SpeechPlayerItem(session: self, speechItem: speechItem, url: speechItem.audioURL) {
// Pause player when we complete the final item.
if isLast {
self.player?.pause()
self.state = .reachedEnd
}
}
self.player?.insert(item, after: nil)
if playWhenReady, self.player?.items().count == 1 {
if atOffset > 0.0 {
item.seek(to: CMTimeMakeWithSeconds(atOffset, preferredTimescale: 600)) { success in
print("success seeking to time: ", success)
self.fireTimer()
}
}
self.startTimer()
self.unpause()
self.setupRemoteControl()
}
}
}
}
}
}
public func pause() {
if let player = player {
player.pause()
state = .paused
}
}
public func unpause() {
if let player = player {
player.rate = Float(playbackRate)
state = .playing
}
}
func formatTimeInterval(_ time: TimeInterval) -> String? {
let componentFormatter = DateComponentsFormatter()
componentFormatter.unitsStyle = .positional
componentFormatter.allowedUnits = time >= 3600 ? [.second, .minute, .hour] : [.second, .minute]
componentFormatter.zeroFormattingBehavior = .pad
return componentFormatter.string(from: time)
}
// What we need is an array of all items in a document, either Utterances if unloaded or AVPlayerItems
// if they have been loaded, then for each one we can calculate a duration
func durationBefore(playerIndex: Int) -> TimeInterval {
let result = durations?.prefix(playerIndex).reduce(0, +) ?? 0
return result
}
func startTimer() {
if timer == nil {
// Update every 100ms
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(fireTimer), userInfo: nil, repeats: true)
timer?.fire()
}
}
// Every second, get the current playing time of the player and refresh the status of the player progressslider
@objc func fireTimer() {
if let player = player {
if player.error != nil || player.currentItem?.error != nil {
print("ERROR IN PLAYBACK")
stop()
}
if player.items().count == 1, let currentTime = player.currentItem?.currentTime(), let duration = player.currentItem?.duration {
if currentTime >= duration {
pause()
state = .reachedEnd
}
}
if let durations = durations {
duration = durations.reduce(0, +)
durationString = formatTimeInterval(duration)
}
}
if let player = player {
switch scrubState {
case .reset:
if let playerItem = player.currentItem as? SpeechPlayerItem {
let itemElapsed = playerItem.status == .readyToPlay ? CMTimeGetSeconds(playerItem.currentTime()) : 0
timeElapsed = durationBefore(playerIndex: playerItem.speechItem.audioIdx) + itemElapsed
timeElapsedString = formatTimeInterval(timeElapsed)
}
if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo {
nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration)
nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying
}
case .scrubStarted:
break
case let .scrubEnded(seekTime):
scrubState = .reset
timeElapsed = seekTime
}
}
// if let item = self.item, let speechItem = player?.currentItem as? SpeechPlayerItem {
// NotificationCenter.default.post(
// name: NSNotification.SpeakingReaderItem,
// object: nil,
// userInfo: [
// "pageID": item.unwrappedID,
// "anchorIdx": String(speechItem.speechItem.htmlIdx)
// ]
// )
// }
}
func clearNowPlayingInfo() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
}
func downloadAndSetArtwork() async {
if let pageId = itemAudioProperties?.itemID, let imageURL = itemAudioProperties?.imageURL {
if let result = try? await URLSession.shared.data(from: imageURL) {
if let downloadedImage = UIImage(data: result.0) {
let artwork = MPMediaItemArtwork(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
downloadedImage
})
DispatchQueue.main.async {
if pageId == self.itemAudioProperties?.itemID {
if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo {
nowPlaying[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying
}
}
}
}
}
}
}
func setupRemoteControl() {
UIApplication.shared.beginReceivingRemoteControlEvents()
if let itemAudioProperties = itemAudioProperties {
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: NSString(string: itemAudioProperties.title),
MPMediaItemPropertyArtist: NSString(string: itemAudioProperties.author ?? "Omnivore"),
MPMediaItemPropertyPlaybackDuration: NSNumber(value: duration),
MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: timeElapsed)
]
}
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in
self.unpause()
return .success
}
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in
self.pause()
return .success
}
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [30, 60]
commandCenter.skipForwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
if let event = event as? MPSkipIntervalCommandEvent {
self.skipForward(seconds: event.interval)
return .success
}
return .commandFailed
}
commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [30, 60]
commandCenter.skipBackwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
if let event = event as? MPSkipIntervalCommandEvent {
self.skipBackwards(seconds: event.interval)
return .success
}
return .commandFailed
}
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
if let event = event as? MPChangePlaybackPositionCommandEvent {
self.seek(to: event.positionTime)
return .success
}
return .commandFailed
}
Task {
await downloadAndSetArtwork()
}
}
func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
let decoder = JSONDecoder()
let speechFileUrl = pathForSpeechFile(itemID: itemID)
if FileManager.default.fileExists(atPath: speechFileUrl.path) {
print("SPEECH FILE ALREADY EXISTS: ", speechFileUrl.path)
let data = try Data(contentsOf: speechFileUrl)
document = try decoder.decode(SpeechDocument.self, from: data)
// If we can't load it from disk we make the API call
if let document = document {
return document
}
}
let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)"
guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else {
throw BasicError.message(messageText: "Invalid audio URL")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
for (header, value) in networker.defaultHeaders {
request.setValue(value, forHTTPHeaderField: header)
}
let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
print("error", result)
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "audioFetch failed. no data received.")
}
let str = String(decoding: data, as: UTF8.self)
print("result speech file: ", str)
let document = try? JSONDecoder().decode(SpeechDocument.self, from: data)
// Cache the file - if it exists
if let document = document {
do {
try? FileManager.default.createDirectory(at: document.audioDirectory, withIntermediateDirectories: true)
try data.write(to: speechFileUrl)
} catch {
print("error writing file", error)
}
}
return document
}
public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully _: Bool) {
if player == self.player {
pause()
player.currentTime = 0
}
}
func setupNotifications() {
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
NotificationCenter.default.addObserver(self,
selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance())
}
@objc func handleInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
// Switch over the interruption type.
switch type {
case .began:
// An interruption began. Update the UI as necessary.
pause()
case .ended:
// An interruption ended. Resume playback, if appropriate.
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
unpause()
} else {}
default: ()
}
}
//
// var document: SpeechDocument {
// let utterances = [
//// Utterance(text: " Published Date: 29 August 2022 "),
//// Utterance(text: "Watch the full video of the Green Shoots Seminar here ."),
//// Utterance(text: "Good morning. Thank you for joining us today."),
//// Utterance(text: " Let me start with the elephant in the room."),
//// Utterance(text: " MAS seems to be sending mixed signals when it comes to crypto and digital assets."),
//// Utterance(text: " On the one hand, MAS is promoting Singapore as a FinTech hub, partnering industry to explore distributed ledger technology (DLT), and supporting innovation in digital asset use cases. MAS has said it wants to attract leading crypto players to Singapore. On the other hand, MAS has a stringent and lengthy licensing process for those who want to carry out crypto-related services. MAS has also been issuing strong warnings against retail investments in cryptocurrencies and has been taking increasingly stronger measures to restrict retail access to cryptocurrencies. "),
//// Utterance(text: "There have been expressions of confusion and concern by some observers."),
//// Utterance(text: " They point to apparent contradictions in MAS stance that although MAS has said that it is excited about the potential to build a crypto or tokenised economy, it imposes a stringent regime. Some others have lamented that MAS has made a u-turn in its digital asset policies. They say that MAS was once making pro-crypto decisions but was now being overly cautious and losing its appeal as a global crypto hub. Yet others see MAS as having struck the right balance, that the crypto winter is proving MAS policies to be right. "),
//// Utterance(text: "What does MAS really want? Well, we know what we want but I think we need to do a better job of explaining it."),
//// Utterance(text: " Before I get to that, it is important to be clear what we are talking about."),
//// Utterance(text: " Public and media attention has tended to focus on cryptocurrencies. But cryptocurrencies are just one part of the entire digital asset ecosystem. To understand the issues more sharply and what the benefits and risks are, we need to be clear what the different components of this ecosystem are. I can understand why there is confusion about cryptocurrencies, blockchains, and digital assets. The inherent complexity of this ecosystem has made it difficult even for MAS to get its messages across. So, today, we will try to do a better job of explaining the ecosystem and its different components and what MAS is actively promoting; what MAS is discouraging; and what are the risks MAS is seeking to manage. My apologies if the next couple of minutes sound like a tutorial but it is important that we are clear about the concepts we are dealing with. "),
//// Utterance(text: "A good place to start is with digital assets."),
//// Utterance(text: " A digital asset is anything of value whose ownership is represented in a digital or computerised form. This is done through a process called tokenisation which involves using a software programme to convert ownership rights over an asset into a digital token. Many items can potentially be tokenised: financial assets like cash and bonds, real assets like artwork and property, even intangible items like carbon credits and computing resources. In other words, anything that has value, when tokenised, becomes a digital asset. Digital assets are typically deployed on distributed ledgers that record the ownership and transfer of ownership of these assets. A blockchain is a type of distributed ledger that organises transaction records into blocks of data which are cryptographically linked together. When deployed on distributed ledgers, digital assets are referred to as crypto assets. "),
//// Utterance(text: "It is this innovative combination of tokenisation and distributed ledgers that offers transformative economic potential."),
//// Utterance(text: " It basically allows anything of value to be represented in digital form, and to be stored and exchanged on a ledger that keeps an immutable record of all transactions. It is this crypto or digital asset ecosystem that supports use cases which can potentially facilitate more efficient transactions, enhance financial inclusion, and unlock economic value. "),
//// Utterance(text: "This digital asset ecosystem is where MAS sees strong potential and is actively promoting."),
//// Utterance(text: "I have not said anything yet about cryptocurrencies. Let me come to that now."),
//// Utterance(text: "A cryptocurrency is the digital asset issued directly by the distributed ledger protocol. "),
//// Utterance(text: " It is often referred to as the distributed ledgers native currency, used as a medium of exchange and store of value within the network, for example to pay transaction fees or incentivise users to keep the network secure. "),
//// Utterance(text: "But cryptocurrencies have taken a life of their own outside of the distributed ledger and this is the source of the crypto worlds problems."),
//// Utterance(text: " Cryptocurrencies are actively traded and heavily speculated upon, with prices that have nothing to do with any underlying economic value related to their use on the distributed ledger. The extreme price volatility of cryptocurrencies rules them out as a viable form of money or investment asset. "),
//// Utterance(text: "This speculation in cryptocurrencies is what MAS strongly discourages and seeks to restrict."),
//// Utterance(text: "Let me now elaborate on Singapores strategy to develop a digital asset ecosystem as well as our regulatory approach to manage the risks of digital assets. "),
//// Utterance(text: "SINGAPORES STRATEGY TO DEVELOP A DIGITAL ASSET ECOSYSTEM "),
//// Utterance(text: "Our vision is to build an innovative and responsible digital asset ecosystem in Singapore. "),
//// Utterance(text: " This is a core part of MAS overall FinTech agenda. As with everything else we do in FinTech, innovation through industry collaboration is key to growing the digital asset ecosystem. Crypto technologies are promising and there is great potential to improve financial services this is a common goal shared by MAS, the financial industry, and the FinTech community. But the only way to find out what works is through experimentation and exploration learning by doing. "),
//// Utterance(text: "We are taking a four-pronged approach to building the digital asset ecosystem."),
//// Utterance(text: " first, explore the potential of distributed ledger technology in promising use cases; second, support the tokenisation of financial and real economy assets; third, enable digital currency connectivity; and fourth, anchor players with strong value propositions and risk management. "),
//// Utterance(text: "EXPLORE POTENTIAL OF DISTRIBUTED LEDGER TECHNOLOGY IN PROMISING USE CASES"),
//// Utterance(text: "The most promising use cases of digital assets in financial services are in cross-border payment and settlement, trade finance, and pre- and post-trade capital market activities. There are several promising developments, including in Singapore."),
//// Utterance(text: " In cross-border payments and settlements, wholesale settlement networks using distributed ledger technologies such as Partior a joint venture among DBS, JP Morgan and Temasek are achieving reductions in settlement time from days to mere minutes. In trade finance, networks like Contour formed by a group of trade banks are establishing common ledgers with traceability to automate document verification, enabling faster financing decisions and lower processing cost. In capital markets, Marketnode a joint venture between SGX and Temasek is leveraging distributed ledger technology to tokenise assets, which reduces the time needed to clear and settle securities transactions, from days to just minutes. "),
//// Utterance(text: "SUPPORT TOKENISATION OF FINANCIAL AND REAL ECONOMY ASSETS "),
//// Utterance(text: "The concept of asset tokenisation has transformative potential, not unlike securitisation 50 years ago. "),
//// Utterance(text: " Tokenisation enables the monetisation of any tangible or intangible asset. It makes it easier to fractionalise an asset or split up its ownership. Tokenisation allows the assets to be traded securely and seamlessly without the need for intermediaries. "),
//// Utterance(text: "There are already interesting applications in Singapore of tokenisation of both financial and real assets."),
//// Utterance(text: " UOB Bank has piloted the issuance of a S$600 million digital bond on Marketnodes servicing platform that facilitates a seamless workflow. OCBC Bank has partnered with MetaVerse Green Exchange to develop green financing products using tokenised carbon credits to help companies offset their carbon emissions. "),
//// Utterance(text: "MAS itself has launched an initiative called Project Guardian to explore the potential of tokenised real economy and financial assets."),
//// Utterance(text: " The first industry pilot, led by DBS Bank, JP Morgan, SBI Group and Marketnode, will explore the institutional trading of tokenised bonds and deposits to improve efficiency and liquidity in wholesale funding markets. "),
//// Utterance(text: "ENABLE DIGITAL CURRENCY CONNECTIVITY"),
//// Utterance(text: "A digital asset ecosystem needs a medium of exchange to facilitate transactions three popular candidates are cryptocurrencies, stablecoins, and central bank digital currencies (CBDCs). How does MAS view each of them?"),
//// Utterance(text: "MAS regards cryptocurrencies as unsuitable for use as money and as highly hazardous for retail investors."),
//// Utterance(text: " Cryptocurrencies lack the three fundamental qualities of money: medium of exchange, store of value, and unit of account. As I mentioned earlier, cryptocurrencies serve a useful function within a blockchain network to reward the participants who help to validate and maintain the record of transactions on the distributed ledger. But outside a blockchain network, cryptocurrencies serve no useful function except as a vehicle for speculation. Since 2017, MAS has been issuing warnings about the substantial risks of investing in cryptocurrencies. "),
//// Utterance(text: "MAS sees good potential in stablecoins provided they are securely backed by high quality reserves and well regulated."),
//// Utterance(text: " Stablecoins are tokens whose value is tied to another asset, usually fiat currencies such as the US dollar. They seek to combine the credibility that comes from their supposed stability, with the benefits of tokenisation, that allow them to be used as payment instruments on distributed ledgers. Stablecoins are beginning to find acceptance outside of the crypto ecosystem. Some firms like Mastercard have integrated popular stablecoins into their payment services. This can be a positive development if stablecoins can make payments cheaper, faster, and safer. But to reap the benefits of stablecoins, regulators must ensure that they are indeed stable. I will talk more about this later. "),
//// Utterance(text: "MAS sees good potential for wholesale CBDCs, especially for cross-border payments and settlements."),
//// Utterance(text: " CBDCs are the direct liability of, and payment instrument, of a central bank. This means that holders of CBDCs will have a direct claim on the central bank that has issued them, similar to how physical currency works today. Wholesale CBDCs are restricted to use by financial institutions. They are akin to the balances which commercial banks place with a central bank today. Wholesale CBDCs on a distributed ledger have the potential to achieve atomic settlement, or the exchange of two linked assets in real-time. They have the potential to radically transform cross-border payments, which today are slow, expensive, and opaque. "),
//// Utterance(text: "MAS does not see a compelling case for retail CBDCs in Singapore."),
//// Utterance(text: " Retail CBDCs are issued to the general public. They are like the cash we carry with us, except in digital form. The case for a retail CBDC in Singapore is not compelling for now, given well-functioning payment systems and broad financial inclusion. Retail electronic payment systems are fast, efficient, and at zero cost, while a residual amount of cash remains in circulation and is unlikely to disappear. Nevertheless, MAS is building the technology infrastructure that would permit issuance of retail CBDCs should conditions change. "),
//// Utterance(text: "MAS has been actively experimenting with digital currency connectivity since 2016. "),
//// Utterance(text: " On the international front, MAS is participating in Project Dunbar, which the Bank for International Settlements Innovation Hub is working on in its Singapore Centre. The project is exploring a common multi-CBDC platform to enable cheaper, faster and safer cross-border payments. Domestically, MAS is working with the industry on Project Orchid to develop the infrastructure and technical competencies necessary to issue a digital Singapore dollar should there be a need to do so in future. "),
//// Utterance(text: "ANCHOR PLAYERS WITH STRONG VALUE PROPOSITIONS AND RISK MANAGEMENT"),
//// Utterance(text: "MAS seeks to anchor in Singapore crypto players who can value add to our digital asset ecosystem and have strong risk management capabilities."),
//// Utterance(text: "A vibrant digital asset ecosystem will encompass a wide range of value-adding activities. Let me cite three examples. "),
//// Utterance(text: " JP Morgan has established its digital asset capabilities in Singapore via its Onyx division, which has pioneered several DLT-based products and initiatives. Offerings include round-the-clock real-time fund transfers with shorter settlement times and no intermediaries. Contour, a global trade finance network of banks, corporates and trade partners, has established its Future of Finance Lab in Singapore. It will conduct research to develop novel, digitally native trade finance solutions. Nansen is a Singapore-based company that analyses more than 100 million blockchain wallet addresses across the world. It provides insights on blockchain network activities and visibility on transacting parties, thereby helping to improve transparency in the digital asset ecosystem globally. "),
//// Utterance(text: "Digital asset activities involving payment services must be licensed under the Payment Services Act. We recognise there is some frustration about MAS licensing process. "),
//// Utterance(text: " Some industry players have described it as a slow and tedious ordeal; others as a bugbear for the fast-moving space. "),
//// Utterance(text: "Given how new the digital asset industry is, it has not been easy for industry players or for MAS."),
//// Utterance(text: " On MAS side, we closely scrutinise licence applicants business models and technologies, so that we can better understand the risks. On the part of applicants, many are not familiar with managing the risks of facilitating illicit finance. MAS engages the applicants closely to assess their understanding of our rules and their ability to meet our standards. This takes a considerable amount of time but it is necessary. "),
//// Utterance(text: "MAS cannot compromise its due diligence process just to make it easy for digital asset players to get a licence. "),
//// Utterance(text: " Given the large number of applicants for licences, we have been prioritising those who demonstrate strong risk management capabilities and the ability to contribute to the growth of Singapores FinTech and digital asset ecosystem. "),
//// Utterance(text: "SINGAPORES REGULATORY APPROACH TO MANAGE DIGITAL ASSET RISKS"),
//// Utterance(text: "Like all innovations, digital asset activities pose risks as well as benefits. "),
//// Utterance(text: " When digital asset activities took off more than five years ago, regulators around the world, including MAS, assessed money laundering and terrorist financing risks as the key areas of concern. "),
//// Utterance(text: "With the rapid growth in scale and complexity of digital asset activities, other risks have surfaced. "),
//// Utterance(text: " Regulators around the world including MAS are therefore stepping up their responses to these new risks. "),
//// Utterance(text: "There are five areas of risk in digital assets that MAS regulatory approach is focused on."),
//// Utterance(text: " first, combat money laundering and terrorist financing risks; second, manage technology and cyber related risks; third, safeguard against harm to retail investors; fourth, uphold the promise of stability in stablecoins; and fifth, mitigate potential financial stability risks "),
//// Utterance(text: "COMBAT MONEY LAUNDERING AND TERRORIST FINANCING RISKS"),
//// Utterance(text: "The key risk that MAS regulation currently addresses is money laundering and terrorist financing. "),
//// Utterance(text: " As users of cryptocurrencies operate through wallet addresses and pseudonyms, cryptocurrencies have made it easier to conduct illicit transactions. The online nature of transactions adds to the risk. In 2020, MAS imposed on providers of digital asset services the same anti-money laundering requirements that apply to other financial institutions. Earlier this year, these rules were expanded to Singapore-incorporated entities providing digital asset services overseas. Singapores requirements are consistent with international standards, namely those of the Financial Action Task Force (FATF). "),
//// Utterance(text: "MANAGE TECHNOLOGY AND CYBER RISKS"),
//// Utterance(text: "Another risk that MAS has sought to address early on is technology and cyber related risk."),
//// Utterance(text: " MAS is one of the earliest regulators to impose on digital asset players the same cyber hygiene standards and technology risk management principles that is expected of other financial institutions. But technology and cyber risks are continually evolving, for example, coding bugs in smart contracts and compromise of digital token wallets or their encryption keys. MAS is reviewing measures to manage these and other technology and cyber risks, including further requirements to protect customers digital assets and uplift system availability. These steps are in line with what other jurisdictions are considering, including in the EU and Japan. "),
//// Utterance(text: "SAFEGUARD AGAINST HARM TO RETAIL INVESTORS"),
//// Utterance(text: "MAS has since 2017 been reiterating the risks of trading in cryptocurrencies. "),
//// Utterance(text: " Prices of cryptocurrencies are highly volatile, driven largely by speculation rather than any underlying economic fundamentals. It is very risky for the public to put their monies in such cryptocurrencies, as the perceived valuation of these cryptocurrencies could plummet rapidly when sentiments shift. We have seen this happen repeatedly. MAS has issued numerous advisories warning consumers that they could potentially lose all the monies they put into cryptocurrencies. Just take for example Luna, the sister token of the so-called stablecoin TerraUSD. Luna was, at one point, worth over US$100 but tumbled to zero. "),
//// Utterance(text: "MAS has taken early decisive steps to mitigate consumer harm."),
//// Utterance(text: " Since January this year, MAS has restricted digital asset players from promoting cryptocurrency services at public spaces. This has led to the dismantling of Bitcoin ATMs and the removal of advertisements in MRT stations. "),
//// Utterance(text: "But despite these warnings and measures, surveys show that consumers are increasingly trading in cryptocurrencies."),
//// Utterance(text: " This appears to be a global phenomenon, not just in Singapore. Many consumers are still enticed by the prospect of sharp price increases in cryptocurrencies. They seem to be irrationally oblivious about the risks of cryptocurrency trading. Consumer-related risks have gained the attention of regulators around the world. "),
//// Utterance(text: "MAS is therefore considering further measures to reduce consumer harm. "),
//// Utterance(text: " Adding frictions on retail access to cryptocurrencies is an area we are contemplating. These may include customer suitability tests and restricting the use of leverage and credit facilities for cryptocurrency trading. But banning retail access to cryptocurrencies is not likely to work. The cryptocurrency world is borderless. With just a mobile phone, Singaporeans have access to any number of crypto exchanges in the world and can buy or sell any number of cryptocurrencies. "),
//// Utterance(text: "The cryptocurrency market is also fraught with risks of market manipulation. "),
//// Utterance(text: " These risks include cornering and wash trades actions that mislead and deceive market participants about prices or trading volumes. They compound the inherent volatility and speculative nature of cryptocurrencies and can severely harm consumers. There is greater impetus now among global regulators to enhance regulations in this space. MAS will also do so. "),
//// Utterance(text: "Safeguarding consumers from harm requires a multi-pronged approach, not just MAS regulation."),
//// Utterance(text: " First, global cooperation is vital to minimise regulatory arbitrage. Cryptocurrency transactions can be conducted from anywhere around the world. MAS is actively involved in international regulatory reviews to enhance market integrity and customer protection in the digital asset space. Second, the industry has an important role in co-creating sensible measures to protect consumer interests. MAS has been sharing its concerns with the industry and inviting views on possible measures to minimise harm to consumers. We will publicly consult on the proposals by October this year. Third, consumers must take responsibility and exercise judgement and caution. No amount of MAS regulation, global co-operation, or industry safeguards will protect consumers from losses if their cryptocurrency holdings lose value. "),
//// Utterance(text: "UPHOLD THE PROMISE OF STABILITY IN STABLECOINS"),
//// Utterance(text: "Stablecoins can realise their potential only if there is confidence in their ability to maintain a stable value. "),
//// Utterance(text: " Many stablecoins lack the ability to uphold the promise of stability in their value. Some of the assets backing these stablecoins such as commercial papers are exposed to credit, market, and liquidity risks There are currently no international standards on the quality of reserve assets backing stablecoins. Globally, regulators are looking to impose requirements such as secure reserve backing and timely redemption at par. MAS will propose for consultation a regulatory approach for stablecoins, also by October. "),
//// Utterance(text: "MITIGATE POTENTIAL FINANCIAL STABILITY RISKS"),
//// Utterance(text: "Financial stability risks from digital asset activities are currently low but bear close monitoring."),
//// Utterance(text: " As the digital asset ecosystem grows, it will be natural for linkages between the traditional banking system and digital assets to grow. There is risk of contagion to financial markets through exposures of financial institutions to digital assets. MAS is working closely with other regulators to design a prudential framework for banks exposures to digital assets. This framework will provide banks with clarity on how to measure the risks of their digital asset exposures, and maintain adequate capital to address these risks. This will reduce risks of spillovers into the traditional banking system. "),
//// Utterance(text: "INNOVATION AND REGULATION HAND-IN-HAND "),
//// Utterance(text: "Singapore wants to be a hub for innovative and responsible digital asset activities that enhance efficiency and create economic value. The development strategy and regulatory approach for digital assets that I have described go hand-in-hand towards achieving this. "),
//// Utterance(text: "Innovation and regulation are not incapable of co-existing. We do not split the difference by being less stringent in our regulation or being less facilitative of innovation. "),
//// Utterance(text: " MAS development strategy makes Singapore one of the most conducive and facilitative jurisdictions for digital assets. At the same time, MAS evolving regulatory approach makes Singapore one of the most comprehensive in managing the risks of digital assets, and among the strictest in areas like discouraging retail investments in cryptocurrencies. "),
//// Utterance(text: "I hope this presentation has made clear that MAS facilitative posture on digital asset activities and restrictive stance on cryptocurrency speculation are not contradictory. It is in fact a synergistic and holistic approach to develop Singapore as an innovative and responsible global digital asset hub. ")
// ]
// // (pageId: item!.unwrappedID, wordCount: 10, utterances: utterances, )
//
// let result = SpeechDocument(averageWPM: 150.0, wordCount: 100, language: "en-US", defaultVoice: currentVoice, utterances: utterances)
// return result
// }
}

View File

@ -1,459 +0,0 @@
//
// AudioSession.swift
//
//
// Created by Jackson Harper on 8/15/22.
//
import AVFoundation
import CryptoKit
import Foundation
import MediaPlayer
import Models
import Utils
public enum AudioSessionState {
case stopped
case paused
case loading
case playing
}
public enum PlayerScrubState {
case reset
case scrubStarted
case scrubEnded(TimeInterval)
}
enum DownloadType: String {
case mp3
case speechMarks
}
enum DownloadPriority: String {
case low
case high
}
// Our observable object class
public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published public var state: AudioSessionState = .stopped
@Published public var item: LinkedItem?
@Published public var timeElapsed: TimeInterval = 0
@Published public var duration: TimeInterval = 0
@Published public var timeElapsedString: String?
@Published public var durationString: String?
let appEnvironment: AppEnvironment
let networker: Networker
var timer: Timer?
var player: AVAudioPlayer?
var downloadTask: Task<Void, Error>?
public init(appEnvironment: AppEnvironment, networker: Networker) {
self.appEnvironment = appEnvironment
self.networker = networker
}
public func play(item: LinkedItem) {
stop()
self.item = item
startAudio()
}
public func stop() {
player?.stop()
clearNowPlayingInfo()
timer = nil
player = nil
item = nil
state = .stopped
timeElapsed = 0
duration = 1
downloadTask?.cancel()
}
public func preload(itemIDs: [String], retryCount: Int = 0) async -> Bool {
var pendingList = [String]()
for pageId in itemIDs {
let permFile = pathForAudioFile(pageId: pageId)
if FileManager.default.fileExists(atPath: permFile.path) {
print("audio file already downloaded: ", permFile)
continue
}
// Attempt to fetch the file if not downloaded already
let result = try? await downloadAudioFile(pageId: pageId, type: .mp3, priority: .low)
if result == nil {
print("audio file had error downloading: ", pageId)
pendingList.append(pageId)
}
if let result = result, result.pending {
print("audio file is pending download: ", pageId)
pendingList.append(pageId)
} else {
print("audio file is downloaded: ", pageId)
}
}
print("audio files pending download: ", pendingList)
if pendingList.isEmpty {
return true
}
if retryCount > 5 {
print("reached max preload depth, stopping preloading")
return false
}
let retryDelayInNanoSeconds = UInt64(retryCount * 2 * 1_000_000_000)
try? await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
return await preload(itemIDs: pendingList, retryCount: retryCount + 1)
}
public var localAudioUrl: URL? {
if let pageId = item?.id {
return FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(pageId + ".mp3")
}
return nil
}
public var scrubState: PlayerScrubState = .reset {
didSet {
switch scrubState {
case .reset:
return
case .scrubStarted:
return
case let .scrubEnded(seekTime):
player?.currentTime = seekTime
}
}
}
public var currentVoice: String {
"en-US-JennyNeural"
}
public func isLoadingItem(item: LinkedItem) -> Bool {
state == .loading && self.item == item
}
public func isPlayingItem(item: LinkedItem) -> Bool {
state == .playing && self.item == item
}
public func skipForward(seconds: Double) {
if let current = player?.currentTime {
player?.currentTime = min(duration, current + seconds)
}
}
public func skipBackwards(seconds: Double) {
if let current = player?.currentTime {
player?.currentTime = max(0, current - seconds)
}
}
public func fileNameForAudioFile(_ pageId: String) -> String {
pageId + "-" + currentVoice + ".mp3"
}
public func pathForAudioFile(pageId: String) -> URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(fileNameForAudioFile(pageId))
}
public func startAudio() {
state = .loading
setupNotifications()
let pageId = item!.unwrappedID
downloadTask = Task {
let result = try? await downloadAudioFile(pageId: pageId, type: .mp3, priority: .high)
if Task.isCancelled { return }
if result == nil {
DispatchQueue.main.async {
NSNotification.operationSuccess(message: "Error generating audio.")
self.stop()
}
}
if let result = result, result.pending {
DispatchQueue.main.async {
NSNotification.operationSuccess(message: "Your audio is being generated.")
}
}
DispatchQueue.main.async {
self.startDownloadedAudioFile(pageId: pageId)
}
}
}
private func startDownloadedAudioFile(pageId: String) {
// Make sure audio file is still correct for the current page
guard item?.unwrappedID == pageId else {
state = .stopped
return
}
// TODO: Maybe check if app is active so it doesn't end up playing later?
let audioUrl = pathForAudioFile(pageId: pageId)
if !FileManager.default.fileExists(atPath: audioUrl.path) {
stop()
return
}
do {
try AVAudioSession.sharedInstance().setCategory(.playback)
player = try AVAudioPlayer(contentsOf: audioUrl)
player?.delegate = self
if player?.play() ?? false {
state = .playing
startTimer()
setupRemoteControl()
}
} catch {
print("error playing MP3 file", error)
try? FileManager.default.removeItem(atPath: audioUrl.path)
state = .stopped
}
}
public func pause() -> Bool {
if let player = player {
player.pause()
state = .paused
return true
}
return false
}
public func unpause() -> Bool {
playAudio()
}
public func playAudio() -> Bool {
if let player = player {
player.play()
state = .playing
return true
}
return false
}
func startTimer() {
if timer == nil {
// Update every 100ms
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(update(_:)), userInfo: nil, repeats: true)
timer?.fire()
}
}
func stopTimer() {
timer = nil
}
func formatTimeInterval(_ time: TimeInterval) -> String? {
let componentFormatter = DateComponentsFormatter()
componentFormatter.unitsStyle = .positional
componentFormatter.allowedUnits = time >= 3600 ? [.second, .minute, .hour] : [.second, .minute]
componentFormatter.zeroFormattingBehavior = .pad
return componentFormatter.string(from: time)
}
// Every second, get the current playing time of the player and refresh the status of the player progressslider
@objc func update(_: Timer) {
if let player = player, player.isPlaying {
duration = player.duration
durationString = formatTimeInterval(duration)
switch scrubState {
case .reset:
timeElapsed = player.currentTime
timeElapsedString = formatTimeInterval(timeElapsed)
if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo {
nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration)
nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying
}
case .scrubStarted:
break
case let .scrubEnded(seekTime):
scrubState = .reset
timeElapsed = seekTime
}
}
}
func clearNowPlayingInfo() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
}
func setupRemoteControl() {
UIApplication.shared.beginReceivingRemoteControlEvents()
if let item = item {
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: NSString(string: item.title ?? "Your Omnivore Article"),
MPMediaItemPropertyArtist: NSString(string: item.author ?? "Omnivore"),
MPMediaItemPropertyPlaybackDuration: NSNumber(value: duration),
MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: timeElapsed)
]
}
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in
self.unpause()
return .success
}
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in
self.pause()
return .success
}
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [30, 60]
commandCenter.skipForwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
if let event = event as? MPSkipIntervalCommandEvent {
self.skipForward(seconds: event.interval)
return .success
}
return .commandFailed
}
commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [30, 60]
commandCenter.skipBackwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
if let event = event as? MPSkipIntervalCommandEvent {
self.skipBackwards(seconds: event.interval)
return .success
}
return .commandFailed
}
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
if let event = event as? MPChangePlaybackPositionCommandEvent {
self.player?.currentTime = event.positionTime
return .success
}
return .commandFailed
}
}
func downloadAudioFile(pageId: String, type: DownloadType, priority: DownloadPriority) async throws -> (pending: Bool, url: URL?) {
let audioUrl = pathForAudioFile(pageId: pageId)
if FileManager.default.fileExists(atPath: audioUrl.path) {
return (pending: false, url: audioUrl)
}
let path = "/api/article/\(pageId)/\(type)/\(priority)/\(currentVoice)"
guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else {
throw BasicError.message(messageText: "Invalid audio URL")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 600
for (header, value) in networker.defaultHeaders {
request.setValue(value, forHTTPHeaderField: header)
}
let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
}
if let httpResponse = result?.1 as? HTTPURLResponse, httpResponse.statusCode == 202 {
return (pending: true, nil)
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "audioFetch failed. no data received.")
}
let tempPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent(UUID().uuidString + ".mp3")
do {
if let googleHash = httpResponse.value(forHTTPHeaderField: "x-goog-hash") {
let hash = Data(Insecure.MD5.hash(data: data)).base64EncodedString()
if !googleHash.contains("md5=\(hash)") {
print("Downloaded mp3 file hashes do not match: returned: \(googleHash) v computed: \(hash)")
throw BasicError.message(messageText: "Downloaded mp3 file hashes do not match: returned: \(googleHash) v computed: \(hash)")
}
}
try data.write(to: tempPath)
try? FileManager.default.removeItem(at: audioUrl)
try FileManager.default.moveItem(at: tempPath, to: audioUrl)
} catch {
print("error writing file: ", error)
let errorMessage = "audioFetch failed. could not write MP3 data to disk"
throw BasicError.message(messageText: errorMessage)
}
return (pending: false, url: audioUrl)
}
public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully _: Bool) {
if player == self.player {
pause()
player.currentTime = 0
}
}
func setupNotifications() {
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
NotificationCenter.default.addObserver(self,
selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance())
}
@objc func handleInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
// Switch over the interruption type.
switch type {
case .began:
// An interruption began. Update the UI as necessary.
pause()
case .ended:
// An interruption ended. Resume playback, if appropriate.
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
unpause()
} else {}
default: ()
}
}
}

View File

@ -0,0 +1,229 @@
//
// SpeechSynthesizer.swift
//
//
// Created by Jackson Harper on 9/5/22.
//
import AVFoundation
import Foundation
import Models
import Utils
struct UtteranceRequest: Codable {
let text: String
let voice: String
let language: String
let rate: String
}
struct Utterance: Decodable {
public let idx: String
public let text: String
public let voice: String?
public let wordOffset: Double
public let wordCount: Double
func toSSML(document: SpeechDocument) throws -> Data? {
let request = UtteranceRequest(text: text, voice: voice ?? document.defaultVoice, language: document.language, rate: "1.1")
return try JSONEncoder().encode(request)
}
}
struct SpeechDocument: Decodable {
static let averageWPM: Double = 195
public let pageId: String
public let wordCount: Double
public let language: String
public let defaultVoice: String
public let utterances: [Utterance]
public func estimatedDuration(utterance: Utterance, speed: Double) -> Double {
utterance.wordCount / SpeechDocument.averageWPM / speed * 60.0
}
var audioDirectory: URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("audio-\(pageId)")
}
}
struct SpeechItem {
let htmlIdx: String
let audioIdx: Int
let audioURL: URL
}
struct SpeechSynthesizer {
typealias Element = SpeechItem
let document: SpeechDocument
let appEnvironment: AppEnvironment
let networker: Networker
init(appEnvironment: AppEnvironment, networker: Networker, document: SpeechDocument) {
self.appEnvironment = appEnvironment
self.networker = networker
self.document = document
}
func estimatedDurations(forSpeed speed: Double) -> [Double] {
document.utterances.map { document.estimatedDuration(utterance: $0, speed: speed) }
}
func fetch(from: Int) -> SpeechSynthesisFetcher {
SpeechSynthesisFetcher(synthesizer: self, start: from)
}
}
struct SpeechSynthesisFetcher: AsyncSequence {
typealias Element = SpeechItem
let start: Int
let synthesizer: SpeechSynthesizer
init(synthesizer: SpeechSynthesizer, start: Int) {
self.start = start
self.synthesizer = synthesizer
}
func makeAsyncIterator() -> SpeechSynthesizerIterator {
SpeechSynthesizerIterator(synthesizer: synthesizer, start: start)
}
struct SpeechSynthesizerIterator: AsyncIteratorProtocol {
let synthesizer: SpeechSynthesizer
init(synthesizer: SpeechSynthesizer, start: Int) {
self.synthesizer = synthesizer
self.currentIdx = start
}
var currentIdx: Int
mutating func next() async -> SpeechItem? {
if Task.isCancelled {
return nil
}
if currentIdx >= synthesizer.document.utterances.count {
return nil
}
let utterance = synthesizer.document.utterances[currentIdx]
let fetched = try? await fetchUtterance(appEnvironment: synthesizer.appEnvironment,
networker: synthesizer.networker,
document: synthesizer.document,
segmentIdx: currentIdx,
utterance: utterance)
if let fetchedURL = fetched {
let item = SpeechItem(htmlIdx: utterance.idx, audioIdx: currentIdx, audioURL: fetchedURL)
currentIdx += 1
return item
}
return nil
}
}
}
struct SynthesizeResult: Decodable {
let audioData: String
// let speechMarks: Any?
}
extension Data {
init?(fromHexEncodedString string: String) {
// Convert 0 ... 9, a ... f, A ...F to their decimal value,
// return nil for all other input characters
func decodeNibble(nibble: UInt8) -> UInt8? {
switch nibble {
case 0x30 ... 0x39:
return nibble - 0x30
case 0x41 ... 0x46:
return nibble - 0x41 + 10
case 0x61 ... 0x66:
return nibble - 0x61 + 10
default:
return nil
}
}
self.init(capacity: string.utf8.count / 2)
var iter = string.utf8.makeIterator()
while let char1 = iter.next() {
guard
let val1 = decodeNibble(nibble: char1),
let char2 = iter.next(),
let val2 = decodeNibble(nibble: char2)
else { return nil }
append(val1 << 4 + val2)
}
}
}
func fetchUtterance(appEnvironment: AppEnvironment,
networker: Networker,
document: SpeechDocument,
segmentIdx: Int,
utterance: Utterance) async throws -> URL
{
let voiceStr = utterance.voice ?? document.defaultVoice
let segmentStr = String(format: "%04d", arguments: [segmentIdx])
let audioPath = document.audioDirectory.appendingPathComponent("\(segmentStr)-\(voiceStr).mp3")
if FileManager.default.fileExists(atPath: audioPath.path) {
print("audio file already downloaded: ", audioPath.path)
return audioPath
}
var request = URLRequest(url: appEnvironment.ttsBaseURL)
request.httpMethod = "POST"
request.timeoutInterval = 600
if let ssml = try utterance.toSSML(document: document) {
request.httpBody = ssml
print("FETCHING: ", String(decoding: ssml, as: UTF8.self))
}
for (header, value) in networker.defaultHeaders {
request.setValue(value, forHTTPHeaderField: header)
}
let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
print("error: ", result?.1 as Any)
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "audioFetch failed. no data received.")
}
let tempPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent(UUID().uuidString + ".mp3")
do {
let decoder = JSONDecoder()
let jsonData = try decoder.decode(SynthesizeResult.self, from: data)
let audioData = Data(fromHexEncodedString: jsonData.audioData)!
if audioData.count < 1 {
throw BasicError.message(messageText: "Audio data is empty")
}
try audioData.write(to: tempPath)
try? FileManager.default.removeItem(at: audioPath)
try FileManager.default.moveItem(at: tempPath, to: audioPath)
print("wrote", audioData.count, "bytes to", audioPath)
} catch {
let errorMessage = "audioFetch failed. could not write MP3 data to disk"
throw BasicError.message(messageText: errorMessage)
}
return audioPath
}

View File

@ -8,6 +8,7 @@ public extension NSNotification {
static let OperationSuccess = Notification.Name("OperationSuccess")
static let OperationFailure = Notification.Name("OperationFailure")
static let ReaderSettingsChanged = Notification.Name("ReaderSettingsChanged")
static let SpeakingReaderItem = Notification.Name("SpeakingReaderItem")
static var pushFeedItemPublisher: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: PushJSONArticle)
@ -29,6 +30,10 @@ public extension NSNotification {
NotificationCenter.default.publisher(for: ReaderSettingsChanged)
}
static var speakingReaderItemPublisher: NotificationCenter.Publisher {
NotificationCenter.default.publisher(for: SpeakingReaderItem)
}
internal var operationMessage: String? {
if let message = userInfo?["message"] as? String {
return message

View File

@ -13,5 +13,6 @@ public enum UserDefaultKey: String {
case lastUsedAppVersion
case lastUsedAppBuildNumber
case lastItemSyncTime
case audioInfoAlertShown
case textToSpeechPlaybackRate
case textToSpeechCurrentVoice
}

View File

@ -20,6 +20,12 @@ public final class OmnivoreWebView: WKWebView {
#if os(iOS)
initNativeIOSMenus()
#endif
NotificationCenter.default.addObserver(forName: NSNotification.Name("SpeakingReaderItem"), object: nil, queue: OperationQueue.main, using: { notification in
if let pageID = notification.userInfo?["pageID"] as? String, let anchorIdx = notification.userInfo?["anchorIdx"] as? String {
self.dispatchEvent(.speakingSection(anchorIdx: anchorIdx))
}
})
}
@available(*, unavailable)
@ -68,9 +74,8 @@ public final class OmnivoreWebView: WKWebView {
}
public func dispatchEvent(_ event: WebViewDispatchEvent) {
evaluateJavaScript(event.script) { obj, err in
if let err = err { print(err) }
if let obj = obj { print(obj) }
evaluateJavaScript(event.script) { _, err in
if let err = err { print("evaluateJavaScript error", err) }
}
}
@ -253,6 +258,7 @@ public enum WebViewDispatchEvent {
case remove
case copyHighlight
case dismissHighlight
case speakingSection(anchorIdx: String)
var script: String {
"var event = new Event('\(eventName)');\(scriptPropertyLine)document.dispatchEvent(event);"
@ -286,6 +292,8 @@ public enum WebViewDispatchEvent {
return "copyHighlight"
case .dismissHighlight:
return "dismissHighlight"
case .speakingSection:
return "speakingSection"
}
}
@ -305,6 +313,8 @@ public enum WebViewDispatchEvent {
return "event.fontFamily = '\(family)';"
case let .saveAnnotation(annotation: annotation):
return "event.annotation = '\(annotation)';"
case let .speakingSection(anchorIdx: anchorIdx):
return "event.anchorIdx = '\(anchorIdx)';"
case .annotate, .highlight, .share, .remove, .copyHighlight, .dismissHighlight:
return ""
}

File diff suppressed because one or more lines are too long

View File

@ -46,6 +46,10 @@ interface AnnotationEvent extends Event {
annotation?: string
}
interface SpeakingSectionEvent extends Event {
anchorIdx?: string
}
export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
const [highlights, setHighlights] = useState(props.highlights)
const [highlightModalAction, setHighlightModalAction] =
@ -387,6 +391,18 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
}
}
const speakingSection = async (event: SpeakingSectionEvent) => {
const item = document.querySelector(`[data-omnivore-anchor-idx="${event.anchorIdx}"]`)
const otherItems = document.querySelectorAll('.speakingSection')
otherItems.forEach((other) => {
if (other != item) {
other?.classList.remove('speakingSection')
}
})
item?.classList.add('speakingSection')
// item?.scrollIntoView()
}
const saveAnnotation = async (event: AnnotationEvent) => {
if (focusedHighlight) {
const annotation = event.annotation ?? ''
@ -417,6 +433,8 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
document.addEventListener('copyHighlight', copy)
document.addEventListener('dismissHighlight', dismissHighlight)
document.addEventListener('saveAnnotation', saveAnnotation)
document.addEventListener('speakingSection', speakingSection)
return () => {
document.removeEventListener('annotate', annotate)
@ -426,6 +444,8 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
document.removeEventListener('copyHighlight', copy)
document.removeEventListener('dismissHighlight', dismissHighlight)
document.removeEventListener('saveAnnotation', saveAnnotation)
document.removeEventListener('speakingSection', speakingSection)
}
})

View File

@ -6,6 +6,11 @@
color-scheme: light dark;
}
.speakingSection {
color: var(--colors-highlightText);
background-color: rgba(255, 210, 52, 0.2);
}
.highlight {
color: var(--colors-highlightText);
background-color: var(--colors-highlightBackground);