-
Notifications
You must be signed in to change notification settings - Fork 312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Speech controller refactor #2348
Speech controller refactor #2348
Conversation
…ynthesizers. These synthesizers are not parametrable.
self.delegate?.voiceController(self, spokenInstructionsDidFailWith: SpeechError.apiError(instruction: modifiedInstruction, | ||
options: options, | ||
underlying: error)) | ||
self.completion?(SpeechError.apiError(instruction: modifiedInstruction, | ||
options: options, | ||
underlying: error)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Error propagation is still a TODO.
I feel the need to have explicit completion
for speak
method to allow it to by async, but at the same time we want to have a delegate
to track various events. Also, some errors may be blocking for Speech Synthesizer to actually speak, others may be not.
What is the best way to handle errors in this situation? We can:
- report any errors occur with via
delegate
, andcompletion
will receive error only if it was blocking (or nil otherwise) - report any errors occur with via
delegate
, andcompletion
will just have booleansuccess
, and thus the last error reported was a blocker. - get rid of
completion
and make it a part ofdelegate
protocol (with one of the 2 above points implemented) - something else?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we have anything bubble up to the delegate, it should be after the speech controller has finished processing all the speech synthesizers. So if there’s an error, try falling back to another speech synthesizer, and only notify the delegate if all the speech synthesizers fail.
/// | ||
func stopSpeaking() | ||
/// | ||
func interruptSpeaking() // ?? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if we need to, but maybe it could be useful to have different ways to shut down ongoing phrase? For example: one method to mark that phrase should be stopped gracefully whenever appropriate, and another to abort it immediately.
/// | ||
func changedIncomingSpokenInstructions(_ instructions: [SpokenInstruction]) | ||
/// | ||
func speak(_ instruction: SpokenInstruction, completion: SpeechSynthesizerCompletion?) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Following what was mentioned in original ticket, I don't feel like such protocol should have API to control ducking
because:
- It is responsibility of a
SpeechSynthesizer
to know and control when it needs toduck
, if it needs at all Ducking
feature is performed via system methods and does not depend on particular synthesizer implementation.
public var muted: Bool = false // ??? | ||
public var volume: Float { | ||
get { | ||
return NavigationSettings.shared.voiceVolume | ||
} | ||
set { | ||
// ?!?!?! | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handling volume and muting with respect to NavigationSettings.shared.voiceVolume
is not implemented yet
@@ -272,7 +191,7 @@ public protocol VoiceControllerDelegate: class, UnimplementedLogging { | |||
- parameter synthesizer: the Speech engine that was used as the fallback. | |||
- parameter error: An error explaining the failure and its cause. | |||
*/ | |||
func voiceController(_ voiceController: RouteVoiceController, didFallBackTo synthesizer: AVSpeechSynthesizer, error: SpeechError) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method feels like a dog-nail that we had to support MapboxVoiceController
's feature of a 'backup' synthesizer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That’s true to some extent, though I think the other consideration was that we wanted to send a telemetry event whenever speech got interrupted. There’s other ways of doing that, though. Do you think it would still be useful for the application to know when all the available speech synthesizers fail?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For sure, knowing that speech synths had fail is important, but I thought that SpeechSynthesizerController
describes a single speech Synth implementation, and thus it "doesn't know" about other implementations (so there is no such term as "fallback" in it's context). At this point, responsibility to manage multiple synths and notify when they all failed should be somewhere else. In current situation that is done by MapboxSpeechSynthesizerController
, which will try to call all synths and if all of them fail - will report the latest error.
import MapboxSpeech | ||
|
||
/// | ||
open class MapboxSpeechSynthesizerController: NSObject, SpeechSynthesizerController { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the separation of concerns would be clearer if we rename this class to SpeechSynthesizerController and rename the SpeechSynthesizerController protocol to SpeechSynthesizing. This class doesn’t need to conform to SpeechSynthesizing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking about this class as some kind of a wrapper or a proxy which manages multiple speech engines. At the same time it is possible to use a single speech engine directly in RouteVoiceController
if user want's to, so MapboxSpeechSynthesizerController
should adopt SpeechSynthesizerController
too.
In this case, MapboxSpeechSynthesizerController
does not have any crucial role in the mechanism and can be safely removed, or used with any custom set of speech synthesizers.
Yes, looks like naming could be better to display the idea :)
Example/CustomViewController.swift
Outdated
@@ -39,7 +39,7 @@ class CustomViewController: UIViewController, MGLMapViewDelegate { | |||
|
|||
let locationManager = simulateLocation ? SimulatedLocationManager(route: userRoute!) : NavigationLocationManager() | |||
navigationService = MapboxNavigationService(route: userRoute!, locationSource: locationManager, simulating: simulateLocation ? .always : .onPoorGPS) | |||
voiceController = MapboxVoiceController(navigationService: navigationService) | |||
// voiceController = MapboxVoiceController(navigationService: navigationService) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we could reimplement this customization hook by exposing the navigation service’s (read-only) speech controller so that the application can append a SpeechSynthesizing instance to the speech controller’s speechSynthesizers
array.
I feel the need to clarify my vision on the refactoring being performed to make sure we are on the same page. As I understood, original problem was that
The solution is to separate these tasks to make such modules replaceable. What I am trying to achieve:
In result, each element can be replaced by User's implementation. User may implement his own With that said, when we talk about Does it match with your vision? :) |
…. Returned route progress to system speech synthesizer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR is mostly in great shape; however, there’s some missing functionality related to locale matching. Once we plumb through the locale, we can merge this PR.
Example/CustomViewController.swift
Outdated
@@ -18,7 +18,6 @@ class CustomViewController: UIViewController, MGLMapViewDelegate { | |||
var userRouteOptions: RouteOptions? | |||
|
|||
// Start voice instructions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment doesn’t make sense anymore; let’s remove it.
let modifiedInstruction = delegate?.speechSynthesizer(self, willSpeak: instruction) ?? instruction | ||
let ssmlText = modifiedInstruction.ssmlText | ||
let options = SpeechOptions(ssml: ssmlText) | ||
options.locale = locale |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By default, this locale should be the speechLocale
from the route response, if present, to ensure that we use the voice that the text was designed for. Otherwise, the fallback to autoupdatingCurrent
is fine, but that shouldn’t be the default. I think this context can be plumbed through the speak(_:during:)
call.
let modifiedInstruction = delegate?.speechSynthesizer(self, willSpeak: instruction) ?? instruction | ||
let ssmlText = modifiedInstruction.ssmlText | ||
let options = SpeechOptions(ssml: ssmlText) | ||
options.locale = locale |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This also needs to set the locale
to the route’s speechLocale
. I think this context can be plumbed through the didPassSpokenInstructionPoint(notification:)
call.
MapboxNavigation/Error.swift
Outdated
@@ -69,5 +68,12 @@ public enum SpeechError: LocalizedError { | |||
- parameter instruction: The instruction that failed. | |||
- parameter progress: the offending `RouteProgress` that omits the expected `SpeechLocale`. | |||
*/ | |||
case undefinedSpeechLocale(instruction: SpokenInstruction, progress: RouteProgress) | |||
case undefinedSpeechLocale(instruction: SpokenInstruction, progress: RouteProgress) // to be removed? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given the points about MapboxSpeechSynthesizer needing speechLocale
, I’m not sure we can remove this case just yet.
|
||
// Only localized languages will have a proper fallback voice | ||
if utterance?.voice == nil { | ||
utterance?.voice = AVSpeechSynthesisVoice(language: Locale.preferredLocalLanguageCountryCode) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should also try to pass in the speechLocale
here too. This code has used Locale.preferredLocalLanguageCountryCode
since #187, even before we moved guidance instruction generation to the server side, so I think this is buggy behavior that we’ve overlooked for a long time.
…ing Locales directly when speaking. Updated corresponding logic
Cartfile.resolved
Outdated
@@ -1,6 +1,6 @@ | |||
binary "https://mapbox-gl-native-ios.s3.amazonaws.com/public/internal/Mapbox-iOS-SDK.json" "5.9.1000" | |||
binary "https://www.mapbox.com/ios-sdk/MapboxAccounts.json" "2.3.0" | |||
binary "https://www.mapbox.com/ios-sdk/MapboxNavigationNative.json" "14.1.5" | |||
binary "https://www.mapbox.com/ios-sdk/MapboxNavigationNative.json" "14.1.6" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if update to nav. native 14.1.6
should be part of this changelist.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's Cartfile.resolved
. This PR does not bump any dependencies. Anyway, after rebasing to the latest commit such change should vanish.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me. Thank you.
2B2B1EDD2424B95600FA18A6 /* ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; | ||
2B2B1EDF2424B95600FA18A6 /* ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITests.swift; sourceTree = "<group>"; }; | ||
2B2B1EE12424B95600FA18A6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | ||
2B2B1EEA2424D0A700FA18A6 /* ViewController+UITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ViewController+UITests.swift"; sourceTree = "<group>"; }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really need ExampleUITests
? I don't see that they're used in this changelist.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That should be a leftover code, which should disappear after rebasing
wait(for: [deinitExpectation], timeout: 3) | ||
} | ||
|
||
func testSystemSpeechSynthesizer() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not critical, but maybe it'd be good to also verify whether properties like muted
, isSpeaking
etc were set correctly as well in test?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a few more tests for parameters, but unfortunately could not find a stable way to test all of them. For example isSpeaking
does not have a dedicated event after it can be "guaranteed to talk". Or volume
value for SystemSpeechSynthesizer
relies on system values and is not controlled by Multiplexed
synth.
…ltiplexed speech synth parameters. Doc corrected.
Corrections applied |
@@ -25,9 +25,12 @@ open class MapboxSpeechSynthesizer: NSObject, SpeechSynthesizing { | |||
public var isSpeaking: Bool { | |||
return audioPlayer?.isPlaying ?? false | |||
} | |||
public var locale: Locale = Locale.autoupdatingCurrent | |||
public var locale: Locale? = Locale.autoupdatingCurrent |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does it mean if the developer sets locale
to nil
, given that the default value is already autoupdatingCurrent
? If we should retain this edge case instead of falling back to autoupdatingCurrent
, then let’s document it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For vocalizing an instruction a propertylocale
or a 'locale' argument to corresponding speak
method can be used.
I was thinking about SpeechSynthesizing
usage scenario as to provide user a flexible ways to provide Locale
s:
- it can be set by default for all instructions (property is set, method argument is nil)
- it can be overridden for a specific instruction (property is set, method argument set)
- it can be required to manually specify
Locale
for each instruction (property is nil, method argument is set)
The latter case requires property to by optional
For MapboxSpeechSynthesizer
I decided to stick to the first variant, so I've added a default value.
let localeCode = [locale.languageCode, locale.regionCode].compactMap{$0}.joined(separator: "-") | ||
|
||
if localeCode == "en-US" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be cleaner to check if locale.languageCode == "en" && locale.regionCode == "US"
.
@@ -5,7 +5,7 @@ import MapboxCoreNavigation | |||
import MapboxSpeech | |||
|
|||
/** | |||
`SpeechSynthesizing` implementation, using `AVSpeechSynthesizer`. Supports only english language. | |||
`SpeechSynthesizing` implementation, using `AVSpeechSynthesizer`. Supports only English language. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This implementation seems to support more than just English now. I don’t think it was ever the intention to have it support only English. Only the Alex voice is English-specific.
Resolves #2268.
RouteVoiceController
together with it's subclassMapboxVoiceController
had 3 goals: tracking navigation events, vocalizingSpokenInstruction
s and handling fallback mechanism for speech synthesizers.In this PR these tasks are separated by introducing
SpeechSynthesizing
protocol.RouteVoiceController
now tracks navigation events and triggers configuredspeechSynthesizer
to vocalize the instructions.SpeechSynthesizersController
handles an arbitrary array ofSpeechSynthesizing
implementations to handle the fallback feature.SystemSpeechSynthesizer
andMapboxSpeechSynthesizer
provide complete and expandable implementations for using iOS built-in speech synthesizer and MapboxSpeech framework respectively.