-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathExtraTTS.m
488 lines (416 loc) · 20.1 KB
/
ExtraTTS.m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
#import "ExtraTTS.h"
#import <Cordova/CDV.h>
#import "acattsioslicense.h"
#import "AcapelaLicense.h"
#import "AcapelaSpeech.h"
#import "ZipFileDownloader.h"
@interface ExtraTTS()
@property AcapelaSpeech *speech;
@property AcapelaLicense *licence;
@property NSURL *voicesFolderURL;
@property BOOL isReady;
@property ZipFileDownloader *zipFileDownloader;
@property CDVInvokedUrlCommand *speakTextCommand;
@property NSMutableDictionary *speakTextParams;
@property (copy) NSString *lastVoice;
@property BOOL debug;
@property NSUInteger maxVoices;
@end
@implementation ExtraTTS
- (void)pluginInitialize
{
self.debug = false;
if (self.debug) NSLog(@"ExtraTTS:init");
// initialize the app, called only once. on Android I set the license here
// call success when done, error if there were any problems
self.isReady = false;
self.maxVoices = 5;
NSFileManager *fileManager = [NSFileManager defaultManager];
NSURL *documentsURL = [[fileManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject];
self.voicesFolderURL = [documentsURL URLByAppendingPathComponent:@"coughdrop_voices"];
if (self.voicesFolderURL) {
if (![[NSFileManager defaultManager] fileExistsAtPath:self.voicesFolderURL.path]) {
[[NSFileManager defaultManager] createDirectoryAtPath:self.voicesFolderURL.path withIntermediateDirectories:YES attributes:nil error:nil];
}
// mark the voices folder as not backed up
[self addSkipBackupAttributeToItemAtURL:self.voicesFolderURL];
// NSURL *backupUrl =[documentsURL URLByAppendingPathComponent:@"Backups"];
// [self addSkipBackupAttributeToItemAtURL:backupUrl];
[AcapelaSpeech setVoicesDirectoryArray:@[self.voicesFolderURL.path]];
} else {
self.isReady = false;
return;
}
self.zipFileDownloader = [ZipFileDownloader new];
// Setup Acapela license and load voices if any are downloaded
self.licence = [[AcapelaLicense alloc] initLicense:[acattsioslicense license]
user:(unsigned int)[acattsioslicense userid]
passwd:(unsigned int)[acattsioslicense password]];
[self initializeAndLoadVoices];
if (!self.licence) {
self.isReady = false;
} else {
self.isReady = true;
}
}
// https://developer.apple.com/library/ios/qa/qa1719/_index.html
- (BOOL)addSkipBackupAttributeToItemAtURL:(NSURL *)URL
{
assert([[NSFileManager defaultManager] fileExistsAtPath: [URL path]]);
NSError *error = nil;
BOOL success = [URL setResourceValue:[NSNumber numberWithBool: YES]
forKey: NSURLIsExcludedFromBackupKey error: &error];
if(!success){
NSLog(@"Error excluding %@ from backup %@", [URL lastPathComponent], error);
}
return success;
}
- (void)hashCheck:(CDVInvokedUrlCommand*)command
{
if (self.debug) NSLog(@"ExtraTTS:hashCheck");
// just testing with reading in values from a hash object and returning a hash result
NSDictionary* options = nil;
NSMutableDictionary* result = [[NSMutableDictionary alloc]init];
NSString* val = nil;
CDVPluginResult* pluginResult = nil;
if ([command.arguments count] > 0) {
options = [command argumentAtIndex:0];
val = [options objectForKey:@"val"];
[result setObject:val forKey:@"result"];
[result setObject:@"set" forKey:@"hash"];
[result setObject:[NSNumber numberWithBool:YES] forKey:@"set"];
}
if(val != nil) {
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:result];
} else {
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:@"val not found"];
}
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
}
- (void)status:(CDVInvokedUrlCommand*)command
{
if (self.debug) NSLog(@"ExtraTTS:status");
// return success({ready: true}) or error({ready:false}) depending on whether init has been called
if (self.isReady) {
[self sendOKWithCommand:command parameters:@{ @"ready" : @YES }];
} else {
[self sendErrorWithCommand:command parameters:@{ @"ready" : @NO }];
}
}
- (void)getAvailableVoices:(CDVInvokedUrlCommand*)command
{
if (self.debug) NSLog(@"ExtraTTS:getAvailableVoices");
// retrieve a list of voices found in the storage path on device using the library
// result should be a json array of json objects with the following attributes:
// {
// language: <voice.language>,
// locale: <voice.locale>,
// active: true,
// name: <voice_id>,
// voice_id: "acap:<voice_id>"
// }
// if any voices are retrieved, take the first 1 or 2 results and initialize/load them for speaking
if (!self.isReady) {
[self sendErrorWithCommand:command message:@"not ready"];
return;
}
NSArray *voices = [self availableVoices];
NSMutableArray *results = [NSMutableArray new];
NSUInteger index = 0;
for (NSString *voice in voices) {
NSDictionary *attributes = [AcapelaSpeech attributesForVoice:voice];
[results addObject:@{
@"language" : attributes[AcapelaVoiceLanguage],
@"locale" : attributes[AcapelaVoiceLocaleIdentifier],
@"active" : (index < self.maxVoices) ? @YES : @NO,
@"name" : voice,
@"voice_id" : [NSString stringWithFormat:@"acap:%@", voice]
}];
index++;
}
[self initializeAndLoadVoices];
[self sendOKWithCommand:command array:results];
}
- (void)downloadVoice:(CDVInvokedUrlCommand*)command
{
if (self.debug) NSLog(@"ExtraTTS:downloadVoice");
// receives a single argument which is a json object and downloads the object.voice_url attribute
// and unzips its contents (a folder) into the storage path on device.
// calls success multiple times, using:
// [pluginResult setKeepCallback:[NSNumber numberWithBool:YES]];
// except on the final completion callback. Success calls should pass a json object with the following attributes:
// {
// percent: <0.0 - 1.0 percent finished>,
// done: <true only when completed, and setKeepCallback is not set>
// }
// for progress percent on Android I go from 0.0 to 0.75 while downloading (if you can't get the content length,
// most of the files are around 50Mb), then from 0.75 to 1.0 for the unzipping process.
// call error on any problems, preferably with a helpful error message. once this is completed, expect an immediate
// follow-up call to getAvailableVoices
if (!self.isReady) {
[self sendErrorWithCommand:command message:@"not ready"];
return;
}
if (self.zipFileDownloader.isDownloading) {
[self sendErrorWithCommand:command message:@"already downloading a voice"];
return;
}
NSDictionary *JSONArgument = [command argumentAtIndex:0];
NSString *voiceURLPath = JSONArgument[@"voice_url"];
if (voiceURLPath) {
NSURL *voiceURL = [NSURL URLWithString:voiceURLPath];
if (voiceURL) {
__weak __typeof(self) weakSelf = self;
[self.zipFileDownloader downloadfileAtURL:voiceURL
andUnZipToURL:self.voicesFolderURL
progressBlock:^(double progress, BOOL isCompleted) {
NSDictionary *params = @{ @"percent" : @(progress),
@"done" : @(isCompleted) };
if (weakSelf.debug) NSLog(@"percent: %.2lf", progress);
if (isCompleted) {
[AcapelaSpeech refreshVoiceList];
[weakSelf sendOKWithCommand:command parameters:params];
} else {
CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:params];
pluginResult.keepCallback = @YES;
[weakSelf.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
}
}
errorBlock:^(NSString *errorMessage) {
[weakSelf sendErrorWithCommand:command message:errorMessage];
}];
return;
}
}
// send error if there we weren't able to get a url to download
[self sendErrorWithCommand:command message:@"invalid download URL"];
}
- (void)deleteVoice:(CDVInvokedUrlCommand*)command
{
if (self.debug) NSLog(@"ExtraTTS:deleteVoice");
// receives a single argument which is a json object and recursively deletes all files in the folder specified
// by object.voice_dir. calls success on completion, error on any problems, prefereably with a helpful error message.
// expect an immediate follow-up call to getAvailableVoices afterward.
if (!self.isReady) {
[self sendErrorWithCommand:command message:@"not ready"];
return;
}
NSDictionary *JSONArgument = [command argumentAtIndex:0];
NSString *voiceDir = JSONArgument[@"voice_dir"];
NSString *voicePath = [[self.voicesFolderURL URLByAppendingPathComponent:voiceDir] path];
// return success if there is no directory
if (![[NSFileManager defaultManager] fileExistsAtPath:voicePath isDirectory:nil]) {
[self sendOKWithCommand:command message:nil];
return;
}
NSError *error;
BOOL success = [[NSFileManager defaultManager] removeItemAtPath:voicePath error:&error];
if (success) {
[AcapelaSpeech refreshVoiceList];
[self sendOKWithCommand:command message:nil];
} else {
[self sendErrorWithCommand:command message:error.localizedDescription];
}
}
- (void)speakText:(CDVInvokedUrlCommand*)command
{
if (self.debug) NSLog(@"ExtraTTS:speakText");
// receives a single argument which is a json object and uses it to generate text. the object looks like this:
// {
// text: <text to speak>,
// voice_id: <voice_id to speak with>,
// pitch: <pitch value, not sure this is settable on ios>,
// on Android, the conversion is: int pitch = (int) Math.min(Math.max(pitchPercent * 100, 70), 130);
// rate: <rate value as a double, with 1.0 being the default>
// on Android, the conversion is: int rate = (int) Math.min(Math.max(ratePercent * 120, 50), 400);
// volume: <volume value as a double, with 1.0 being the default>
// Android doesn't support this, so I'm not sure what a good conversion would be..
// }
// if there is an error speaking text, error should be called. otherwise success should be called, but not until
// the text has finished being spoken. I believe you'll be listening with a delegate to catch that event.
// when speaking, make sure to add the vce=speaker=voice_id voice switch option to the beginning of the text.
// on success, pass the following data:
// {
// text: <unmodified spoken text>,
// voice_id: <voice_id>,
// pitch: <pitch>,
// rate: <rate>
// volume: <volume>
// modified_text: <modified text, including vce=speaker=voice_id switch option>,
// modified_rate: <computed rate value from 50 - 700>,
// modified_pitch: nil,
// modified_volume: <computed volume value from 15 - 200>
// }
if (!self.isReady) {
[self sendErrorWithCommand:command message:@"not ready"];
return;
}
if (self.speakTextCommand) {
[self sendErrorWithCommand:command message:@"already speaking text"];
return;
}
NSDictionary *JSONArgument = [command argumentAtIndex:0];
float ratePercent = 1.0;
NSNumber *rateNumber = JSONArgument[@"rate"];
if (rateNumber) {
ratePercent = rateNumber.floatValue;
}
// words to speak per minute (50 to 700). We max it out at 400
float rate = MIN(MAX(ratePercent * 120, 50), 400);
[self.speech setRate:rate];
float pitchPercent = 1.0;
NSNumber *pitchNumber = JSONArgument[@"pitch"];
if (pitchNumber) {
pitchPercent = pitchNumber.floatValue;
}
// Shaping value from 70 to 140. We max out at 130
float pitch = MIN(MAX(pitchPercent * 100, 70), 130);
[self.speech setVoiceShaping:pitch];
float volumePercent = 1.0;
NSNumber *volumeNumber = JSONArgument[@"volume"];
if (volumeNumber) {
volumePercent = volumeNumber.floatValue;
}
// From 15 to 200
float volume = MIN(MAX(ratePercent * 190, 15), 200);
[self.speech setVolume:volume];
NSString *voiceId = JSONArgument[@"voice_id"];
NSString *text = JSONArgument[@"text"];
if (text) {
if (voiceId) {
NSString *prefix = @"acap:";
if ([voiceId hasPrefix:prefix]) {
voiceId = [voiceId stringByReplacingCharactersInRange:NSMakeRange(0, prefix.length) withString:@""];
}
if (![self.lastVoice isEqualToString:voiceId]) {
[self.speech setVoice:voiceId license:self.licence.license userid:self.licence.user password:self.licence.passwd mode:@""];
self.lastVoice = voiceId;
}
NSString *modifiedText = [NSString stringWithFormat:@"\\vce=speaker=%@\\%@", voiceId, text];
self.speakTextCommand = command;
self.speakTextParams = [NSMutableDictionary dictionaryWithDictionary:@{
@"text" : text,
@"voice_id" : voiceId,
@"pitch" : @(pitchPercent),
@"rate" : @(ratePercent),
@"volume" : @(volumePercent),
@"modified_text" : modifiedText,
@"modified_rate" : @(rate),
@"modified_pitch" : @(pitch),
@"modified_volume" : @(volume)
}];
[self.speech startSpeakingString:modifiedText];
return;
}
}
[self sendErrorWithCommand:command message:@"not enough information sent to speak text"];
}
- (void)stopSpeakingText:(CDVInvokedUrlCommand*)command
{
if (self.debug) NSLog(@"ExtraTTS:stopSpeakingText");
// stops the currently-speaking text, if any. keep in mind that if a text is stopped, its success callback from
// speakText must be triggered, with an additional attribute set on the result object, object.interrupted = true.
if (!self.isReady) {
[self sendErrorWithCommand:command message:@"not ready"];
return;
}
if (self.speakTextCommand && self.speakTextParams) {
[self.speakTextParams setObject:@YES forKey:@"interrupted"];
[self sendOKWithCommand:self.speakTextCommand parameters:self.speakTextParams];
self.speakTextCommand = nil;
self.speakTextParams = nil;
}
[self.speech stopSpeaking];
[self sendOKWithCommand:command message:nil];
}
#pragma mark - AcapelaSpeechDelegate
- (void)speechSynthesizer:(AcapelaSpeech *)sender didFinishSpeaking:(BOOL)finishedSpeaking
{
if (self.speakTextCommand && self.speakTextParams) {
[self sendOKWithCommand:self.speakTextCommand parameters:self.speakTextParams];
self.speakTextCommand = nil;
self.speakTextParams = nil;
}
}
- (void)speechSynthesizer:(AcapelaSpeech *)sender didFinishSpeaking:(BOOL)finishedSpeaking textIndex:(int)index
{
}
- (void)speechSynthesizer:(AcapelaSpeech *)sender willSpeakWord:(NSRange)characterRange ofString:(NSString *)string
{
}
- (void)speechSynthesizer:(AcapelaSpeech *)sender willSpeakViseme:(short)visemeCode
{
}
- (void)speechSynthesizer:(AcapelaSpeech *)sender didEncounterSyncMessage:(NSString *)errorMessage
{
if (self.speakTextCommand) {
[self sendErrorWithCommand:self.speakTextCommand message:errorMessage];
}
}
#pragma mark - Acapela Helpers
- (NSArray *)availableVoices
{
// If there is a file in the voice directory that isn't recognized,
// [AcapelaSpeech availableVoices] returns a voice with the name "name"
// and should be ignored since it causes errors. I keep seeing a file
// called "__MACOSX" that causes this error
NSArray *acapelaVoices = [AcapelaSpeech availableVoices];
NSMutableArray *voices = [NSMutableArray new];
for (NSString *voice in acapelaVoices) {
if (![voice isEqualToString:@"name"]) {
[voices addObject:voice];
}
}
return voices;
}
- (void)initializeAndLoadVoices
{
NSArray *voices = [self availableVoices];
NSArray *voicesToAdd = [voices subarrayWithRange:NSMakeRange(0, MIN(voices.count, self.maxVoices))];
if (voicesToAdd.count > 0) {
NSString *voicesString = [voicesToAdd componentsJoinedByString:@","];
if (self.speech) {
[self.speech setVoice:voicesString license:self.licence.license userid:self.licence.user password:self.licence.passwd mode:@""];
} else {
self.speech = [[AcapelaSpeech alloc] initWithVoice:voicesString license:self.licence mode:@""];
}
[self.speech setDelegate:self];
}
}
#pragma mark - Cordova Helpers
- (void)sendOKWithCommand:(CDVInvokedUrlCommand *)command message:(NSString *)message
{
[self sendResultWithStatus:CDVCommandStatus_OK command:command message:message];
}
- (void)sendOKWithCommand:(CDVInvokedUrlCommand *)command parameters:(NSDictionary *)parameters
{
[self sendResultWithStatus:CDVCommandStatus_OK command:command parameters:parameters];
}
- (void)sendOKWithCommand:(CDVInvokedUrlCommand *)command array:(NSArray *)array
{
[self sendResultWithStatus:CDVCommandStatus_OK command:command array:array];
}
- (void)sendErrorWithCommand:(CDVInvokedUrlCommand *)command message:(NSString *)message
{
[self sendResultWithStatus:CDVCommandStatus_ERROR command:command message:message];
}
- (void)sendErrorWithCommand:(CDVInvokedUrlCommand *)command parameters:(NSDictionary *)parameters
{
[self sendResultWithStatus:CDVCommandStatus_ERROR command:command parameters:parameters];
}
- (void)sendResultWithStatus:(CDVCommandStatus)status command:(CDVInvokedUrlCommand *)command message:(NSString *)message
{
CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:status messageAsString:message];
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
}
- (void)sendResultWithStatus:(CDVCommandStatus)status command:(CDVInvokedUrlCommand *)command parameters:(NSDictionary *)parameters
{
CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:status messageAsDictionary:parameters];
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
}
- (void)sendResultWithStatus:(CDVCommandStatus)status command:(CDVInvokedUrlCommand *)command array:(NSArray *)array
{
CDVPluginResult *pluginResult = [CDVPluginResult resultWithStatus:status messageAsArray:array];
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
}
@end