@g9tm/lynxcontrols-native 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,8 @@
4
4
  # package version, which always equals the pinned G9MAUIControls version (npm run check:versions).
5
5
  Pod::Spec.new do |s|
6
6
  s.name = 'g9lynx-native'
7
- s.version = '1.4.3'
8
- s.summary = 'G9 SQLite and host native modules and the <g9-video> element for Lynx (sqlite3, NSUserDefaults, UIKit, AVFoundation).'
7
+ s.version = '1.4.5'
8
+ s.summary = 'G9 SQLite, host and speech native modules and the <g9-video> element for Lynx (sqlite3, NSUserDefaults, UIKit, AVFoundation, Speech).'
9
9
  s.homepage = 'https://github.com/imankari/g9lynxcontrols'
10
10
  s.license = { :type => 'MIT' }
11
11
  s.author = 'G9LynxControls'
@@ -16,7 +16,8 @@ Pod::Spec.new do |s|
16
16
  # flattened Pods/Headers/Public cannot resolve from the host app (found building apps/ios-host, 2026-10-06).
17
17
  s.header_mappings_dir = 'src'
18
18
  s.libraries = 'sqlite3'
19
- s.frameworks = 'UIKit', 'AVFoundation'
19
+ # Speech: G9SpeechModule (dictation; the host's Info.plist carries NSSpeechRecognitionUsageDescription).
20
+ s.frameworks = 'UIKit', 'AVFoundation', 'Speech'
20
21
  s.requires_arc = true
21
22
  s.dependency 'Lynx', '4.0.3'
22
23
 
@@ -0,0 +1,18 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <Lynx/LynxContextModule.h>
3
+ #import "generated/G9SpeechModuleSpec.h"
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ // Lynx 4.0.3 has no @LynxNativeModuleRegister: hosts register the module on their LynxConfig
8
+ // (`[config registerModule:G9SpeechModule.class]`). The host's Info.plist needs NSMicrophoneUsageDescription and
9
+ // NSSpeechRecognitionUsageDescription.
10
+
11
+ /**
12
+ * `NativeModules.G9SpeechModule` on iOS (`G9SpeechToText`): `SFSpeechRecognizer` over `AVAudioEngine`, one session at a
13
+ * time; partial hypotheses go out as the global event `g9:speech` (`{"session","text"}`). TWIN: G9SpeechModule.kt.
14
+ */
15
+ @interface G9SpeechModule : NSObject <G9SpeechModuleSpec, LynxContextModule>
16
+ @end
17
+
18
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,201 @@
1
+ #import "G9SpeechModule.h"
2
+ #import <AVFoundation/AVFoundation.h>
3
+ #import <Lynx/LynxContext.h>
4
+ #import <Speech/Speech.h>
5
+
6
+ typedef void (^G9Reply)(id envelope);
7
+
8
+ static NSDictionary *G9Ok(id value) {
9
+ return @{@"ok" : @YES, @"value" : value ?: [NSNull null]};
10
+ }
11
+
12
+ static NSDictionary *G9Fail(NSString *code, NSString *message) {
13
+ return @{@"ok" : @NO, @"error" : @{@"code" : code, @"message" : message ?: @""}};
14
+ }
15
+
16
+ static NSLocale *G9LocaleOf(NSString *locale) {
17
+ return [NSLocale localeWithLocaleIdentifier:locale.length ? locale : @"en-US"];
18
+ }
19
+
20
+ /** `granted` needs both speech recognition and the microphone; `restricted` is the device's policy. */
21
+ static NSString *G9PermissionState(SFSpeechRecognizerAuthorizationStatus speech, BOOL mic) {
22
+ if (speech == SFSpeechRecognizerAuthorizationStatusRestricted) return @"restricted";
23
+ return speech == SFSpeechRecognizerAuthorizationStatusAuthorized && mic ? @"granted" : @"denied";
24
+ }
25
+
26
+ /** One dictation: the recogniser's task, the request the engine tap feeds, the reply that settles once. */
27
+ @interface G9SpeechSession : NSObject
28
+ @property(nonatomic, copy) NSString *identifier;
29
+ @property(nonatomic, copy, nullable) G9Reply reply;
30
+ @property(nonatomic, strong, nullable) SFSpeechRecognitionTask *task;
31
+ @property(nonatomic, strong, nullable) SFSpeechAudioBufferRecognitionRequest *request;
32
+ @property(nonatomic, copy) NSString *best;
33
+ @property(nonatomic) BOOL settled;
34
+ @end
35
+
36
+ @implementation G9SpeechSession
37
+ @end
38
+
39
+ @interface G9SpeechModule ()
40
+ @property(nonatomic, weak, nullable) LynxContext *lynxContext;
41
+ @property(nonatomic, strong) AVAudioEngine *engine;
42
+ @property(nonatomic, strong, nullable) G9SpeechSession *current;
43
+ @end
44
+
45
+ @implementation G9SpeechModule
46
+
47
+ + (NSString *)name {
48
+ return @"G9SpeechModule";
49
+ }
50
+
51
+ + (NSDictionary<NSString *, NSString *> *)methodLookup {
52
+ return @{
53
+ @"isAvailable" : NSStringFromSelector(@selector(isAvailable:callback:)),
54
+ @"requestPermission" : NSStringFromSelector(@selector(requestPermission:)),
55
+ @"listen" : NSStringFromSelector(@selector(listen:locale:callback:)),
56
+ @"cancel" : NSStringFromSelector(@selector(cancel:)),
57
+ };
58
+ }
59
+
60
+ - (instancetype)initWithLynxContext:(LynxContext *)context {
61
+ if ((self = [super init])) {
62
+ _lynxContext = context;
63
+ _engine = [[AVAudioEngine alloc] init];
64
+ }
65
+ return self;
66
+ }
67
+
68
+ - (instancetype)init {
69
+ if ((self = [super init])) _engine = [[AVAudioEngine alloc] init];
70
+ return self;
71
+ }
72
+
73
+ - (void)isAvailable:(NSString *)locale callback:(id)callback {
74
+ SFSpeechRecognizer *r = [[SFSpeechRecognizer alloc] initWithLocale:G9LocaleOf(locale)];
75
+ ((G9Reply)callback)(G9Ok(@(r != nil && r.isAvailable)));
76
+ }
77
+
78
+ - (void)requestPermission:(id)callback {
79
+ G9Reply reply = [(G9Reply)callback copy];
80
+ [SFSpeechRecognizer requestAuthorization:^(SFSpeechRecognizerAuthorizationStatus speech) {
81
+ [AVAudioSession.sharedInstance requestRecordPermission:^(BOOL granted) {
82
+ reply(G9Ok(G9PermissionState(speech, granted)));
83
+ }];
84
+ }];
85
+ }
86
+
87
+ - (void)listen:(NSString *)session locale:(NSString *)locale callback:(id)callback {
88
+ G9Reply reply = [(G9Reply)callback copy];
89
+ dispatch_async(dispatch_get_main_queue(), ^{
90
+ BOOL mic = AVAudioSession.sharedInstance.recordPermission == AVAudioSessionRecordPermissionGranted;
91
+ if (![G9PermissionState(SFSpeechRecognizer.authorizationStatus, mic) isEqualToString:@"granted"]) {
92
+ reply(G9Ok(@{@"status" : @"denied"}));
93
+ return;
94
+ }
95
+ SFSpeechRecognizer *recognizer = [[SFSpeechRecognizer alloc] initWithLocale:G9LocaleOf(locale)];
96
+ if (recognizer == nil || !recognizer.isAvailable) {
97
+ reply(G9Fail(@"speech-unavailable", @"No speech recogniser for this language on this device."));
98
+ return;
99
+ }
100
+ if (self.current) [self finish:self.current value:@{@"status" : @"cancelled"}];
101
+
102
+ G9SpeechSession *s = [[G9SpeechSession alloc] init];
103
+ s.identifier = session ?: @"";
104
+ s.reply = reply;
105
+ s.best = @"";
106
+ s.request = [[SFSpeechAudioBufferRecognitionRequest alloc] init];
107
+ s.request.shouldReportPartialResults = YES;
108
+ self.current = s;
109
+
110
+ NSError *error = nil;
111
+ AVAudioSession *audio = AVAudioSession.sharedInstance;
112
+ [audio setCategory:AVAudioSessionCategoryRecord mode:AVAudioSessionModeMeasurement options:AVAudioSessionCategoryOptionDuckOthers error:&error];
113
+ if (!error) [audio setActive:YES withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:&error];
114
+ if (error) {
115
+ [self fail:s code:@"speech-recognition" message:error.localizedDescription];
116
+ return;
117
+ }
118
+ AVAudioInputNode *input = self.engine.inputNode;
119
+ SFSpeechAudioBufferRecognitionRequest *request = s.request;
120
+ [input removeTapOnBus:0];
121
+ [input installTapOnBus:0
122
+ bufferSize:1024
123
+ format:[input outputFormatForBus:0]
124
+ block:^(AVAudioPCMBuffer *buffer, AVAudioTime *when) {
125
+ [request appendAudioPCMBuffer:buffer];
126
+ }];
127
+ [self.engine prepare];
128
+ if (![self.engine startAndReturnError:&error]) {
129
+ [self fail:s code:@"speech-recognition" message:error.localizedDescription];
130
+ return;
131
+ }
132
+ __weak G9SpeechModule *weakSelf = self;
133
+ s.task = [recognizer recognitionTaskWithRequest:request
134
+ resultHandler:^(SFSpeechRecognitionResult *result, NSError *taskError) {
135
+ dispatch_async(dispatch_get_main_queue(), ^{
136
+ G9SpeechModule *me = weakSelf;
137
+ if (!me || s.settled) return;
138
+ if (result) {
139
+ s.best = result.bestTranscription.formattedString ?: @"";
140
+ if (!result.isFinal && s.best.length) [me emitPartial:s];
141
+ if (result.isFinal) {
142
+ NSString *text = [s.best stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
143
+ [me finish:s value:text.length ? @{@"status" : @"recognized", @"text" : text} : @{@"status" : @"no-match"}];
144
+ return;
145
+ }
146
+ }
147
+ if (taskError) {
148
+ // 1110 = no speech detected; a cancelled task reports 216 / 301.
149
+ NSInteger code = taskError.code;
150
+ if (code == 1110) [me finish:s value:@{@"status" : @"no-match"}];
151
+ else if (code == 216 || code == 301) [me finish:s value:@{@"status" : @"cancelled"}];
152
+ else [me fail:s code:@"speech-recognition" message:taskError.localizedDescription];
153
+ }
154
+ });
155
+ }];
156
+ });
157
+ }
158
+
159
+ - (void)cancel:(NSString *)session {
160
+ dispatch_async(dispatch_get_main_queue(), ^{
161
+ G9SpeechSession *s = self.current;
162
+ if (!s || ![s.identifier isEqualToString:session ?: @""]) return;
163
+ [s.task cancel];
164
+ [self finish:s value:@{@"status" : @"cancelled"}];
165
+ });
166
+ }
167
+
168
+ - (void)emitPartial:(G9SpeechSession *)s {
169
+ NSData *data = [NSJSONSerialization dataWithJSONObject:@{@"session" : s.identifier, @"text" : s.best} options:0 error:nil];
170
+ NSString *json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}";
171
+ @try {
172
+ [self.lynxContext sendGlobalEvent:@"g9:speech" withParams:@[ json ]];
173
+ } @catch (NSException *ex) {
174
+ // A context being torn down must not take the recogniser down with it.
175
+ }
176
+ }
177
+
178
+ - (void)finish:(G9SpeechSession *)s value:(NSDictionary *)value {
179
+ if (s.settled) return;
180
+ s.settled = YES;
181
+ [self releaseSession:s];
182
+ if (s.reply) s.reply(G9Ok(value));
183
+ }
184
+
185
+ - (void)fail:(G9SpeechSession *)s code:(NSString *)code message:(NSString *)message {
186
+ if (s.settled) return;
187
+ s.settled = YES;
188
+ [self releaseSession:s];
189
+ if (s.reply) s.reply(G9Fail(code, message));
190
+ }
191
+
192
+ - (void)releaseSession:(G9SpeechSession *)s {
193
+ if (self.current == s) self.current = nil;
194
+ if (self.engine.isRunning) [self.engine stop];
195
+ [self.engine.inputNode removeTapOnBus:0];
196
+ [s.request endAudio];
197
+ s.task = nil;
198
+ [AVAudioSession.sharedInstance setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil];
199
+ }
200
+
201
+ @end
@@ -0,0 +1,16 @@
1
+ // Generated by @lynx-js/autolink-codegen. Do not edit.
2
+ #import <Foundation/Foundation.h>
3
+ #import <Lynx/LynxModule.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ @protocol G9SpeechModuleSpec <LynxModule>
8
+
9
+ - (void)isAvailable:(NSString *)locale callback:(id)callback;
10
+ - (void)requestPermission:(id)callback;
11
+ - (void)listen:(NSString *)session locale:(NSString *)locale callback:(id)callback;
12
+ - (void)cancel:(NSString *)session;
13
+
14
+ @end
15
+
16
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,2 @@
1
+ // Generated by @lynx-js/autolink-codegen. Do not edit.
2
+ #import "G9SpeechModuleSpec.h"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@g9tm/lynxcontrols-native",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Android and iOS platform layer: the G9SqliteModule and G9HostModule native modules (Autolink) and their JS adapters.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,14 +38,14 @@
38
38
  "check:android-types": "node scripts/android-bridge-types.mjs"
39
39
  },
40
40
  "dependencies": {
41
- "@g9tm/lynxcontrols-foundation": "1.4.3",
42
- "@g9tm/lynxcontrols-sqlite": "1.4.3"
41
+ "@g9tm/lynxcontrols-foundation": "1.4.5",
42
+ "@g9tm/lynxcontrols-sqlite": "1.4.5"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@lynx-js/react": ">=0.126.2 <0.127.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@g9tm/lynxcontrols-lynxtron": "1.4.3",
48
+ "@g9tm/lynxcontrols-lynxtron": "1.4.5",
49
49
  "@lynx-js/autolink-codegen": "0.6.0"
50
50
  }
51
51
  }
@@ -44,3 +44,24 @@ export declare class G9HostModule {
44
44
  */
45
45
  assetUri(path: string, callback: (reply: Record<string, unknown>) => void): void;
46
46
  }
47
+
48
+ /**
49
+ * Speech recognition for dictation into fields (`G9SpeechToText`, MAUI `IG9SpeechToText`): Android `SpeechRecognizer`,
50
+ * iOS `SFSpeechRecognizer` over `AVAudioEngine`. One session at a time; interim hypotheses arrive as the global event
51
+ * `g9:speech` with one JSON argument `{"session","text"}` while the user speaks.
52
+ *
53
+ * @lynxmodule
54
+ */
55
+ export declare class G9SpeechModule {
56
+ /** Whether a recogniser exists for `locale` on this device (no permission prompt): `{ ok, value: boolean }`. */
57
+ isAvailable(locale: string, callback: (reply: Record<string, unknown>) => void): void;
58
+ /** Asks for the microphone (and, on iOS, speech recognition): `{ ok, value: 'granted' | 'denied' | 'restricted' }`. */
59
+ requestPermission(callback: (reply: Record<string, unknown>) => void): void;
60
+ /**
61
+ * Listens once and replies exactly once: `{ ok, value: { status: 'recognized', text } | { status: 'no-match' |
62
+ * 'cancelled' | 'denied' } }`, or `{ ok: false, error }` for a recogniser fault.
63
+ */
64
+ listen(session: string, locale: string, callback: (reply: Record<string, unknown>) => void): void;
65
+ /** Ends `session` early: its `listen` replies `cancelled`. Unknown or finished sessions are ignored. */
66
+ cancel(session: string): void;
67
+ }