From 8cdf219b1432b8f726dbfe51353073f70238b78e Mon Sep 17 00:00:00 2001 From: JunsuChoi Date: Thu, 10 Sep 2026 14:35:04 +0900 Subject: [PATCH] [tizen_stt] Add STT plugin Add speech-to-text support for Samsung TVs using the Tizen STT engine. Supports recognition results, language selection, and session control. --- .github/labeler.yml | 2 + .github/recipe.yaml | 1 + README.md | 2 + packages/tizen_stt/CHANGELOG.md | 3 + packages/tizen_stt/LICENSE | 25 + packages/tizen_stt/README.md | 42 ++ packages/tizen_stt/example/README.md | 9 + .../plugin_integration_test.dart | 51 ++ packages/tizen_stt/example/lib/main.dart | 209 ++++++++ packages/tizen_stt/example/pubspec.yaml | 24 + .../example/test_driver/integration_test.dart | 7 + packages/tizen_stt/example/tizen/.gitignore | 12 + packages/tizen_stt/example/tizen/App.cs | 20 + .../tizen_stt/example/tizen/Runner.csproj | 19 + .../example/tizen/shared/res/ic_launcher.png | Bin 0 -> 1443 bytes .../example/tizen/tizen-manifest.xml | 17 + packages/tizen_stt/lib/tizen_stt.dart | 78 +++ packages/tizen_stt/lib/tizen_stt_event.dart | 107 ++++ packages/tizen_stt/lib/tizen_stt_state.dart | 57 ++ packages/tizen_stt/pubspec.yaml | 24 + .../test/tizen_stt_method_channel_test.dart | 137 +++++ packages/tizen_stt/tizen/.gitignore | 5 + .../tizen_stt/tizen/inc/tizen_stt_plugin.h | 27 + packages/tizen_stt/tizen/project_def.prop | 23 + .../tizen_stt/tizen/src/tizen_stt_plugin.cc | 492 ++++++++++++++++++ 25 files changed, 1393 insertions(+) create mode 100644 packages/tizen_stt/CHANGELOG.md create mode 100644 packages/tizen_stt/LICENSE create mode 100644 packages/tizen_stt/README.md create mode 100644 packages/tizen_stt/example/README.md create mode 100644 packages/tizen_stt/example/integration_test/plugin_integration_test.dart create mode 100644 packages/tizen_stt/example/lib/main.dart create mode 100644 packages/tizen_stt/example/pubspec.yaml create mode 100644 packages/tizen_stt/example/test_driver/integration_test.dart create mode 100644 packages/tizen_stt/example/tizen/.gitignore create mode 100644 packages/tizen_stt/example/tizen/App.cs create mode 100644 packages/tizen_stt/example/tizen/Runner.csproj create mode 100644 packages/tizen_stt/example/tizen/shared/res/ic_launcher.png create mode 100644 packages/tizen_stt/example/tizen/tizen-manifest.xml create mode 100644 packages/tizen_stt/lib/tizen_stt.dart create mode 100644 packages/tizen_stt/lib/tizen_stt_event.dart create mode 100644 packages/tizen_stt/lib/tizen_stt_state.dart create mode 100644 packages/tizen_stt/pubspec.yaml create mode 100644 packages/tizen_stt/test/tizen_stt_method_channel_test.dart create mode 100644 packages/tizen_stt/tizen/.gitignore create mode 100644 packages/tizen_stt/tizen/inc/tizen_stt_plugin.h create mode 100644 packages/tizen_stt/tizen/project_def.prop create mode 100644 packages/tizen_stt/tizen/src/tizen_stt_plugin.cc diff --git a/.github/labeler.yml b/.github/labeler.yml index a57a165c3..a640c57cb 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -55,6 +55,8 @@ - packages/tizen_package_manager/**/* "p: tizen_rpc_port": - packages/tizen_rpc_port/**/* +"p: tizen_stt": + - packages/tizen_stt/**/* "p: tizen_window_manager": - packages/tizen_window_manager/**/* "p: url_launcher": diff --git a/.github/recipe.yaml b/.github/recipe.yaml index 20dc26aea..906fa34b3 100644 --- a/.github/recipe.yaml +++ b/.github/recipe.yaml @@ -34,6 +34,7 @@ plugins: geolocator: [] in_app_purchase: [] network_info_plus: [] + tizen_stt: [] video_player_avplay: [] video_player_videohole: [] diff --git a/README.md b/README.md index dcb4a16ba..2579b04a2 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ The _"non-endorsed"_ status means that the plugin is not endorsed by the origina | [**tizen_notification**](packages/tizen_notification) | (Tizen-only) | [![pub package](https://img.shields.io/pub/v/tizen_notification.svg)](https://pub.dev/packages/tizen_notification) | N/A | | [**tizen_package_manager**](packages/tizen_package_manager) | (Tizen-only) | [![pub package](https://img.shields.io/pub/v/tizen_package_manager.svg)](https://pub.dev/packages/tizen_package_manager) | N/A | | [**tizen_rpc_port**](packages/tizen_rpc_port) | (Tizen-only) | [![pub package](https://img.shields.io/pub/v/tizen_rpc_port.svg)](https://pub.dev/packages/tizen_rpc_port) | N/A | +| [**tizen_stt**](packages/tizen_stt) | (Tizen-only) | [![pub package](https://img.shields.io/pub/v/tizen_stt.svg)](https://pub.dev/packages/tizen_stt) | N/A | | [**tizen_window_manager**](packages/tizen_window_manager) | (Tizen-only) | [![pub package](https://img.shields.io/pub/v/tizen_window_manager.svg)](https://pub.dev/packages/tizen_window_manager) | N/A | | [**url_launcher_tizen**](packages/url_launcher) | [url_launcher](https://pub.dev/packages/url_launcher) (1st-party) | [![pub package](https://img.shields.io/pub/v/url_launcher_tizen.svg)](https://pub.dev/packages/url_launcher_tizen) | No | | [**video_player_avplay**](packages/video_player_avplay) | (Tizen-only) | [![pub package](https://img.shields.io/pub/v/video_player_avplay.svg)](https://pub.dev/packages/video_player_avplay) | N/A | @@ -85,6 +86,7 @@ The _"non-endorsed"_ status means that the plugin is not endorsed by the origina | [**tizen_notification**](packages/tizen_notification) | ✔️ | ✔️ | ❌ | API not supported | | [**tizen_package_manager**](packages/tizen_package_manager) | ✔️ | ✔️ | ✔️ | | [**tizen_rpc_port**](packages/tizen_rpc_port) | ✔️ | ✔️ | ✔️ | +| [**tizen_stt**](packages/tizen_stt) | ✔️ | ❌ | ❌ | Requires a supported microphone. | | [**tizen_window_manager**](packages/tizen_window_manager) | ✔️ | ✔️ | ✔️ | | [**url_launcher_tizen**](packages/url_launcher) | ✔️ | ❌ | ❌ | No browser app | | [**video_player_avplay**](packages/video_player_avplay) | ✔️ | ❌ | ❌ | See README for details | diff --git a/packages/tizen_stt/CHANGELOG.md b/packages/tizen_stt/CHANGELOG.md new file mode 100644 index 000000000..75674c185 --- /dev/null +++ b/packages/tizen_stt/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +* Initial Tizen STT engine-recorder implementation. diff --git a/packages/tizen_stt/LICENSE b/packages/tizen_stt/LICENSE new file mode 100644 index 000000000..4b455a8ce --- /dev/null +++ b/packages/tizen_stt/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2026 Samsung Electronics Co., Ltd. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of the copyright holder nor the names of the + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/tizen_stt/README.md b/packages/tizen_stt/README.md new file mode 100644 index 000000000..7ddf9c352 --- /dev/null +++ b/packages/tizen_stt/README.md @@ -0,0 +1,42 @@ +# tizen_stt + +Speech-to-text for Samsung TVs using the Tizen STT engine. + +## Requirements + +Requires a supported microphone. + +Add the following to `tizen/tizen-manifest.xml`: + +```xml + + http://tizen.org/privilege/recorder + http://tizen.org/privilege/internet + + + +``` + +## Usage + +```dart +import 'package:tizen_stt/tizen_stt.dart'; + +final stt = TizenStt(); +final subscription = stt.events.listen((event) { + if (event.isPartialResult || event.isFinalResult) print(event.text); + if (event.isError) print(event.errorName ?? event.message); +}); + +await stt.initialize(); +await stt.startListening(); +// After the user speaks: +await stt.stopListening(); +// After receiving the result, when finished: +await stt.dispose(); +await subscription.cancel(); +``` + +Use `getLanguages()` to select a language and `cancel()` to discard recognition. +All instances share one session. Handle `PlatformException` from control calls. +See the [example](example) for a complete app. diff --git a/packages/tizen_stt/example/README.md b/packages/tizen_stt/example/README.md new file mode 100644 index 000000000..0e69a4ca7 --- /dev/null +++ b/packages/tizen_stt/example/README.md @@ -0,0 +1,9 @@ +# tizen_stt_example + +[![pub package](https://img.shields.io/pub/v/tizen_stt.svg)](https://pub.dev/packages/tizen_stt) + +Demonstrates how to use the tizen_stt plugin. + +## Getting Started + +To run this app on your Tizen device, use [flutter-tizen](https://github.com/flutter-tizen/flutter-tizen). diff --git a/packages/tizen_stt/example/integration_test/plugin_integration_test.dart b/packages/tizen_stt/example/integration_test/plugin_integration_test.dart new file mode 100644 index 000000000..5ebd9f0c2 --- /dev/null +++ b/packages/tizen_stt/example/integration_test/plugin_integration_test.dart @@ -0,0 +1,51 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:tizen_stt/tizen_stt.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final TizenStt stt = TizenStt(); + + setUp(() async { + expect(await stt.initialize(), TizenSttState.ready); + }); + + tearDown(() async { + await stt.dispose(); + }); + + Future expectTransition( + Future Function() action, + TizenSttState state, + ) async { + final Future event = stt.events + .timeout(const Duration(seconds: 10)) + .firstWhere( + (TizenSttEvent event) => event.isError || event.state == state) + .then((TizenSttEvent event) => + expect(event.state, state, reason: event.raw.toString())); + await Future.wait(>[event, action()]); + } + + test('languages and session disposal', () async { + expect(await stt.getLanguages(), isNotEmpty); + expect(await stt.getState(), TizenSttState.ready); + await stt.dispose(); + expect(await stt.getState(), TizenSttState.notCreated); + }); + + test('recording can be cancelled', () async { + await expectTransition(stt.startListening, TizenSttState.recording); + await expectTransition(stt.cancel, TizenSttState.ready); + expect(await stt.getState(), TizenSttState.ready); + }); + + test('stopping recording starts processing', () async { + await expectTransition(stt.startListening, TizenSttState.recording); + await expectTransition(stt.stopListening, TizenSttState.processing); + }); +} diff --git a/packages/tizen_stt/example/lib/main.dart b/packages/tizen_stt/example/lib/main.dart new file mode 100644 index 000000000..1160c7663 --- /dev/null +++ b/packages/tizen_stt/example/lib/main.dart @@ -0,0 +1,209 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:tizen_stt/tizen_stt.dart'; + +void main() => runApp(const MyApp()); + +/// Minimal STT test application. +class MyApp extends StatelessWidget { + /// Creates the example application. + const MyApp({super.key}); + + @override + Widget build(BuildContext context) => const MaterialApp(home: SttPage()); +} + +/// Exercises the native session lifecycle. +class SttPage extends StatefulWidget { + /// Creates the test page. + const SttPage({super.key}); + + @override + State createState() => _SttPageState(); +} + +class _SttPageState extends State with WidgetsBindingObserver { + final TizenStt _stt = TizenStt(); + late final StreamSubscription _subscription; + TizenSttState _state = TizenSttState.notCreated; + bool _busy = false; + bool _partial = false; + List _languages = []; + String? _language; + String _text = ''; + String _error = ''; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _subscription = _stt.events.listen((TizenSttEvent event) { + if (!mounted) { + return; + } + setState(() { + if (event.isStateChange) { + _state = event.state!; + } + if (event.isPartialResult || event.isFinalResult) { + _text = event.text ?? ''; + } + if (event.isError) { + _error = event.errorName ?? event.message ?? 'Recognition failed'; + } + }); + }, onError: (Object error) { + if (mounted) { + setState(() => _error = error.toString()); + } + }); + } + + Future _run(Future Function() action) async { + if (_busy) { + return; + } + setState(() { + _busy = true; + _error = ''; + }); + try { + await action(); + } on PlatformException catch (error) { + if (mounted) { + setState(() => _error = '${error.code}: ${error.message ?? ''}'); + } + } finally { + if (mounted) { + setState(() => _busy = false); + } + } + } + + Future _refresh() async { + final TizenSttState state = await _stt.getState(); + if (mounted) { + setState(() => _state = state); + } + } + + Future _initialize() async { + await _stt.initialize(); + final List languages = await _stt.getLanguages(); + if (mounted) { + setState(() { + _languages = languages; + _language = null; + }); + } + await _refresh(); + } + + Future _record() async { + if (_state == TizenSttState.recording) { + await _stt.stopListening(); + } else { + setState(() => _text = ''); + await _stt.startListening( + language: _language, + recognitionType: _partial + ? TizenSttRecognitionType.freePartial + : TizenSttRecognitionType.free); + } + await _refresh(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + // Release the microphone even if a command is in flight. + unawaited( + _stt.dispose().then((_) => _refresh()).catchError((Object error) { + if (mounted) { + setState(() => _error = error.toString()); + } + })); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_subscription.cancel()); + unawaited( + _stt.dispose().catchError((Object error) => debugPrint('$error'))); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Tizen STT')), + body: ListView(padding: const EdgeInsets.all(24), children: [ + Text('State: ${_state.name}', key: const Key('state')), + if (_busy) const Text('Working…'), + if (_error.isNotEmpty) Text(_error, key: const Key('error')), + Wrap(spacing: 12, children: [ + ElevatedButton( + autofocus: true, + onPressed: !_busy && + (_state == TizenSttState.notCreated || + _state == TizenSttState.created) + ? () => _run(_initialize) + : null, + child: const Text('Initialize')), + ElevatedButton( + key: const Key('record_button'), + onPressed: !_busy && + (_state == TizenSttState.ready || + _state == TizenSttState.recording) + ? () => _run(_record) + : null, + child: Text( + _state == TizenSttState.recording ? 'Stop' : 'Record')), + ElevatedButton( + onPressed: !_busy && + (_state == TizenSttState.recording || + _state == TizenSttState.processing) + ? () => _run(() async { + await _stt.cancel(); + await _refresh(); + }) + : null, + child: const Text('Cancel')), + ElevatedButton( + onPressed: !_busy && _state != TizenSttState.notCreated + ? () => _run(() async { + await _stt.dispose(); + await _refresh(); + }) + : null, + child: const Text('Dispose')), + ]), + DropdownButton( + value: _language, + hint: const Text('Automatic language'), + items: _languages + .map((String language) => DropdownMenuItem( + value: language, child: Text(language))) + .toList(), + onChanged: !_busy && _state == TizenSttState.ready + ? (String? value) => setState(() => _language = value) + : null), + CheckboxListTile( + title: const Text('Partial results (if supported)'), + value: _partial, + onChanged: !_busy && _state == TizenSttState.ready + ? (bool? value) => setState(() => _partial = value!) + : null), + const SizedBox(height: 24), + SelectableText( + _text.isEmpty ? 'Recognized text appears here.' : _text), + ]), + ); +} diff --git a/packages/tizen_stt/example/pubspec.yaml b/packages/tizen_stt/example/pubspec.yaml new file mode 100644 index 000000000..de7349079 --- /dev/null +++ b/packages/tizen_stt/example/pubspec.yaml @@ -0,0 +1,24 @@ +name: tizen_stt_example +description: Demonstrates how to use the tizen_stt plugin. +publish_to: none + +environment: + sdk: ">=3.1.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + tizen_stt: + path: ../ + +dev_dependencies: + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + integration_test_tizen: ^2.0.1 + +flutter: + uses-material-design: true diff --git a/packages/tizen_stt/example/test_driver/integration_test.dart b/packages/tizen_stt/example/test_driver/integration_test.dart new file mode 100644 index 000000000..63206eef5 --- /dev/null +++ b/packages/tizen_stt/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/tizen_stt/example/tizen/.gitignore b/packages/tizen_stt/example/tizen/.gitignore new file mode 100644 index 000000000..ac0738be2 --- /dev/null +++ b/packages/tizen_stt/example/tizen/.gitignore @@ -0,0 +1,12 @@ +flutter/ +.vs/ +*.user +bin/ +obj/ + +# Tizen Core CLI (tz) related files +tizen_dotnet_project.yaml +*.csproj.backup + +# Flutter-tizen dependency information file +.app.deps.json diff --git a/packages/tizen_stt/example/tizen/App.cs b/packages/tizen_stt/example/tizen/App.cs new file mode 100644 index 000000000..6dd4a6356 --- /dev/null +++ b/packages/tizen_stt/example/tizen/App.cs @@ -0,0 +1,20 @@ +using Tizen.Flutter.Embedding; + +namespace Runner +{ + public class App : FlutterApplication + { + protected override void OnCreate() + { + base.OnCreate(); + + GeneratedPluginRegistrant.RegisterPlugins(this); + } + + static void Main(string[] args) + { + var app = new App(); + app.Run(args); + } + } +} diff --git a/packages/tizen_stt/example/tizen/Runner.csproj b/packages/tizen_stt/example/tizen/Runner.csproj new file mode 100644 index 000000000..e6b2d1c31 --- /dev/null +++ b/packages/tizen_stt/example/tizen/Runner.csproj @@ -0,0 +1,19 @@ + + + + Exe + tizen60 + + + + + + + + + + %(RecursiveDir) + + + + diff --git a/packages/tizen_stt/example/tizen/shared/res/ic_launcher.png b/packages/tizen_stt/example/tizen/shared/res/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/packages/tizen_stt/example/tizen/tizen-manifest.xml b/packages/tizen_stt/example/tizen/tizen-manifest.xml new file mode 100644 index 000000000..f4f78526d --- /dev/null +++ b/packages/tizen_stt/example/tizen/tizen-manifest.xml @@ -0,0 +1,17 @@ + + + + + + ic_launcher.png + + + + + http://tizen.org/privilege/recorder + http://tizen.org/privilege/internet + + + + + diff --git a/packages/tizen_stt/lib/tizen_stt.dart b/packages/tizen_stt/lib/tizen_stt.dart new file mode 100644 index 000000000..fb787e8f1 --- /dev/null +++ b/packages/tizen_stt/lib/tizen_stt.dart @@ -0,0 +1,78 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import 'tizen_stt_event.dart'; +import 'tizen_stt_state.dart'; + +export 'tizen_stt_event.dart'; +export 'tizen_stt_state.dart'; + +/// Speech recognition through the Tizen STT service. +/// +/// All instances share one native session. Subscribe to [events] before use, +/// and call [dispose] when finished. This plugin uses the engine's recorder; +/// microphone routing and recognition support depend on the installed engine. +class TizenStt { + static const MethodChannel _channel = MethodChannel('tizen_stt'); + static const EventChannel _eventChannel = EventChannel('tizen_stt/events'); + static Stream? _events; + + /// Broadcast stream of state changes, partial/final results and errors. + Stream get events => + _events ??= _eventChannel.receiveBroadcastStream().map((dynamic event) => + TizenSttEvent.fromMap(event as Map)); + + /// Creates the session and waits for READY, or throws [PlatformException]. + /// + /// Preparation times out after 10 seconds. Call once before starting; + /// concurrent initialization requests are rejected. If already prepared, + /// returns the current state without interrupting recognition. + Future initialize() => _invokeState('initialize'); + + /// Starts recording after [initialize] has completed. + /// + /// A null [language] uses the engine's automatic language selection. + /// Otherwise use a value returned by [getLanguages], such as `en_US`. + /// Recognition types and partial results depend on the installed engine. + /// The returned state is a snapshot; use [events] for later transitions. + Future startListening({ + String? language, + String recognitionType = TizenSttRecognitionType.free, + }) => + _invokeState('start', { + 'language': language, + 'type': recognitionType, + }); + + /// Stops recording and requests a final result (or error) through [events]. + Future stopListening() => _invokeState('stop'); + + /// Discards recognition. Cancelling preparation releases the session. + /// Does nothing when idle or already disposed. + Future cancel() => _invokeState('cancel'); + + /// Returns the current session state without creating a session. + Future getState() => _invokeState('getState'); + + /// Lists languages supported by the engine after [initialize] completes. + Future> getLanguages() async => + (await _channel.invokeListMethod('getLanguages'))!; + + /// Cancels pending work and releases the shared native session. + /// Safe to call repeatedly. Call [initialize] to use the session again. + Future dispose() => _channel.invokeMethod('dispose'); + + Future _invokeState( + String method, [ + Map? arguments, + ]) async { + final Map? result = + await _channel.invokeMapMethod(method, arguments); + return TizenSttState.fromStatus(result!); + } +} diff --git a/packages/tizen_stt/lib/tizen_stt_event.dart b/packages/tizen_stt/lib/tizen_stt_event.dart new file mode 100644 index 000000000..591101f7b --- /dev/null +++ b/packages/tizen_stt/lib/tizen_stt_event.dart @@ -0,0 +1,107 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'tizen_stt_state.dart'; + +/// Recognition type passed to `TizenStt.startListening`, matching Tizen's +/// `STT_RECOGNITION_TYPE_*` string constants. +/// +/// Which types an engine supports varies; [TizenSttRecognitionType.free] +/// (free-form dictation) is the common default. +class TizenSttRecognitionType { + const TizenSttRecognitionType._(); + + /// Free-form dictation (`STT_RECOGNITION_TYPE_FREE`). + static const String free = 'stt.recognition.type.FREE'; + + /// Free-form dictation with partial results (`...FREE.PARTIAL`). + static const String freePartial = 'stt.recognition.type.FREE.PARTIAL'; + + /// Short search phrases (`STT_RECOGNITION_TYPE_SEARCH`). + static const String search = 'stt.recognition.type.SEARCH'; + + /// Web search phrases (`STT_RECOGNITION_TYPE_WEB_SEARCH`). + static const String webSearch = 'stt.recognition.type.WEB_SEARCH'; + + /// Map/navigation phrases (`STT_RECOGNITION_TYPE_MAP`). + static const String map = 'stt.recognition.type.MAP'; +} + +/// An event emitted on `TizenStt.events`. +/// +/// One of three [type]s: `state` (engine state changed), `result` (recognition +/// output — partial, final or error), or `error`. The original platform payload is always +/// available via [raw]; the typed getters below cover the common cases. +class TizenSttEvent { + /// Creates an event with its original native payload. + const TizenSttEvent({ + required this.type, + required this.raw, + this.text, + this.message, + }); + + /// Builds an event from the raw platform map sent over the event channel. + factory TizenSttEvent.fromMap(Map map) { + return TizenSttEvent( + type: map['type'] as String? ?? 'unknown', + raw: Map.from(map), + text: map['text'] as String?, + message: map['message'] as String?, + ); + } + + /// Event category: `state`, `result`, or `error`. + final String type; + + /// Recognized text, for `result` events. + final String? text; + + /// Human-readable recognition message, if provided by the engine. + final String? message; + + /// The unmodified platform payload, for fields not exposed as getters. + final Map raw; + + /// Whether this is a `state` transition event. + bool get isStateChange => type == 'state'; + + /// The new engine state, for `state` events; otherwise `null`. + TizenSttState? get state { + if (!isStateChange) { + return null; + } + final Object? current = raw['current']; + if (current is Map) { + return TizenSttState.fromName(current['name'] as String?); + } + return null; + } + + /// Whether this is a final recognition result. + bool get isFinalResult => type == 'result' && raw['event'] == 'final'; + + /// Whether this is a partial (in-progress) recognition result. + bool get isPartialResult => type == 'result' && raw['event'] == 'partial'; + + /// Whether this is an error event. + bool get isError => + type == 'error' || (type == 'result' && raw['event'] == 'error'); + + /// The Tizen error name (e.g. `STT_ERROR_OPERATION_FAILED`), for `error` + /// events; otherwise `null`. + String? get errorName => isError ? raw['name'] as String? : null; + + /// Best text to show for this event: recognized [text], else [message], + /// else the raw payload. + String get displayText { + if (text != null && text!.isNotEmpty) { + return text!; + } + if (message != null && message!.isNotEmpty) { + return message!; + } + return raw.toString(); + } +} diff --git a/packages/tizen_stt/lib/tizen_stt_state.dart b/packages/tizen_stt/lib/tizen_stt_state.dart new file mode 100644 index 000000000..fe2376b24 --- /dev/null +++ b/packages/tizen_stt/lib/tizen_stt_state.dart @@ -0,0 +1,57 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Lifecycle state of the STT engine, mirroring Tizen's `stt_state_e`. +/// +/// Transitions are driven by the engine and surfaced both as the return value +/// of the control methods on `TizenStt` and as `state` events on +/// `TizenStt.events`. +enum TizenSttState { + /// No engine handle exists yet, or it was released by `dispose()`. + notCreated, + + /// Handle created but not prepared yet (`STT_STATE_CREATED`). + created, + + /// Prepared and idle — ready to start listening (`STT_STATE_READY`). + ready, + + /// Capturing audio (`STT_STATE_RECORDING`). + recording, + + /// Recognizing the captured audio (`STT_STATE_PROCESSING`). + processing, + + /// The state could not be determined. + unknown; + + /// Maps a state name (as emitted by the platform) to a [TizenSttState]. + static TizenSttState fromName(String? name) { + switch (name) { + case 'notCreated': + return TizenSttState.notCreated; + case 'created': + return TizenSttState.created; + case 'ready': + return TizenSttState.ready; + case 'recording': + return TizenSttState.recording; + case 'processing': + return TizenSttState.processing; + default: + return TizenSttState.unknown; + } + } + + /// Extracts the state from a platform status map shaped like + /// `{ "state": { "code": int, "name": String } }`. Also accepts a bare + /// `{ "name": String }` map (as carried by `state` events). + static TizenSttState fromStatus(Map status) { + final Object? state = status['state']; + if (state is Map) { + return fromName(state['name'] as String?); + } + return fromName(status['name'] as String?); + } +} diff --git a/packages/tizen_stt/pubspec.yaml b/packages/tizen_stt/pubspec.yaml new file mode 100644 index 000000000..45bbde8a0 --- /dev/null +++ b/packages/tizen_stt/pubspec.yaml @@ -0,0 +1,24 @@ +name: tizen_stt +description: A Flutter plugin for speech recognition using the Tizen STT service. +version: 0.1.0 +homepage: https://github.com/flutter-tizen/plugins +repository: https://github.com/flutter-tizen/plugins/tree/main/packages/tizen_stt + +environment: + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + tizen: + pluginClass: TizenSttPlugin + fileName: tizen_stt_plugin.h diff --git a/packages/tizen_stt/test/tizen_stt_method_channel_test.dart b/packages/tizen_stt/test/tizen_stt_method_channel_test.dart new file mode 100644 index 000000000..cdb4d8044 --- /dev/null +++ b/packages/tizen_stt/test/tizen_stt_method_channel_test.dart @@ -0,0 +1,137 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tizen_stt/tizen_stt.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final TestDefaultBinaryMessenger messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const MethodChannel channel = MethodChannel('tizen_stt'); + const MethodChannel eventChannel = MethodChannel('tizen_stt/events'); + final TizenStt stt = TizenStt(); + final List calls = []; + + setUp(() { + calls.clear(); + messenger.setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + if (call.method == 'getLanguages') { + return ['en_US', 'ko_KR']; + } + if (call.method == 'dispose') { + return null; + } + return { + 'state': {'name': 'ready'} + }; + }); + messenger.setMockMethodCallHandler( + eventChannel, (MethodCall call) async => null); + }); + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + messenger.setMockMethodCallHandler(eventChannel, null); + }); + + test('control calls forward arguments and decode state and languages', + () async { + expect(await stt.initialize(), TizenSttState.ready); + expect( + await stt.startListening( + language: 'ko_KR', + recognitionType: TizenSttRecognitionType.freePartial), + TizenSttState.ready); + expect(calls.last.arguments, { + 'language': 'ko_KR', + 'type': TizenSttRecognitionType.freePartial + }); + await stt.startListening(); + expect(calls.last.arguments, { + 'language': null, + 'type': TizenSttRecognitionType.free + }); + await stt.stopListening(); + await stt.cancel(); + await stt.getState(); + expect(await stt.getLanguages(), ['en_US', 'ko_KR']); + await stt.dispose(); + expect(calls.map((MethodCall call) => call.method), [ + 'initialize', + 'start', + 'start', + 'stop', + 'cancel', + 'getState', + 'getLanguages', + 'dispose' + ]); + }); + + test('initialization stays pending and propagates native failure', () async { + final Completer ready = Completer(); + messenger.setMockMethodCallHandler( + channel, (MethodCall call) => ready.future); + bool completed = false; + final Future initializing = + stt.initialize().then((TizenSttState state) { + completed = true; + return state; + }); + await Future.delayed(Duration.zero); + expect(completed, isFalse); + final Future expectation = expectLater( + initializing, + throwsA(isA().having((PlatformException e) => e.code, + 'code', 'STT_ERROR_ENGINE_NOT_FOUND'))); + ready.completeError(PlatformException(code: 'STT_ERROR_ENGINE_NOT_FOUND')); + await expectation; + }); + + test('shared event stream decodes state, results and both error forms', + () async { + final List events = []; + final StreamSubscription first = + stt.events.listen(events.add); + final List otherEvents = []; + final StreamSubscription second = + TizenStt().events.listen(otherEvents.add); + await Future.delayed(Duration.zero); + for (final Map event in >[ + { + 'type': 'state', + 'current': {'name': 'ready'} + }, + {'type': 'result', 'event': 'partial', 'text': 'hello'}, + { + 'type': 'result', + 'event': 'final', + 'text': 'hello world' + }, + { + 'type': 'result', + 'event': 'error', + 'message': 'no match' + }, + {'type': 'error', 'name': 'STT_ERROR_NO_SPEECH'}, + ]) { + final ByteData data = + const StandardMethodCodec().encodeSuccessEnvelope(event); + await messenger.handlePlatformMessage('tizen_stt/events', data, (_) {}); + } + expect(events.first.state, TizenSttState.ready); + expect(events[1].isPartialResult, isTrue); + expect(events[2].isFinalResult, isTrue); + expect(events[2].text, 'hello world'); + expect(events[3].isError, isTrue); + expect(events[4].errorName, 'STT_ERROR_NO_SPEECH'); + expect(otherEvents.length, events.length); + await first.cancel(); + await second.cancel(); + }); +} diff --git a/packages/tizen_stt/tizen/.gitignore b/packages/tizen_stt/tizen/.gitignore new file mode 100644 index 000000000..a2a7d62b1 --- /dev/null +++ b/packages/tizen_stt/tizen/.gitignore @@ -0,0 +1,5 @@ +.cproject +.sign +crash-info/ +Debug/ +Release/ diff --git a/packages/tizen_stt/tizen/inc/tizen_stt_plugin.h b/packages/tizen_stt/tizen/inc/tizen_stt_plugin.h new file mode 100644 index 000000000..52cea5bef --- /dev/null +++ b/packages/tizen_stt/tizen/inc/tizen_stt_plugin.h @@ -0,0 +1,27 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_PLUGIN_TIZEN_STT_PLUGIN_H_ +#define FLUTTER_PLUGIN_TIZEN_STT_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void TizenSttPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_TIZEN_STT_PLUGIN_H_ diff --git a/packages/tizen_stt/tizen/project_def.prop b/packages/tizen_stt/tizen/project_def.prop new file mode 100644 index 000000000..45337128d --- /dev/null +++ b/packages/tizen_stt/tizen/project_def.prop @@ -0,0 +1,23 @@ +# See https://docs.tizen.org/application/tizen-studio/native-tools/project-conversion +# for details. + +APPNAME = tizen_stt_plugin +type = staticLib +profile = common-6.0 + +# Source files +USER_SRCS += src/*.cc + +# User defines +USER_DEFS = +USER_UNDEFS = +USER_CPP_DEFS = FLUTTER_PLUGIN_IMPL +USER_CPP_UNDEFS = + +# User includes +USER_INC_DIRS = inc src +USER_INC_FILES = +USER_CPP_INC_FILES = + +# Tizen Native API dependencies +USER_PKGS = stt glib-2.0 diff --git a/packages/tizen_stt/tizen/src/tizen_stt_plugin.cc b/packages/tizen_stt/tizen/src/tizen_stt_plugin.cc new file mode 100644 index 000000000..a5cbb4afe --- /dev/null +++ b/packages/tizen_stt/tizen/src/tizen_stt_plugin.cc @@ -0,0 +1,492 @@ +// Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "tizen_stt_plugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using EncodableValue = flutter::EncodableValue; +using EncodableMap = flutter::EncodableMap; +using EncodableList = flutter::EncodableList; +using EventSink = flutter::EventSink; +using StreamHandlerError = flutter::StreamHandlerError; + +constexpr char kMethodChannelName[] = "tizen_stt"; +constexpr char kEventChannelName[] = "tizen_stt/events"; + +std::string ErrorName(int error) { + switch (error) { + case STT_ERROR_NONE: + return "STT_ERROR_NONE"; + case STT_ERROR_OUT_OF_MEMORY: + return "STT_ERROR_OUT_OF_MEMORY"; + case STT_ERROR_IO_ERROR: + return "STT_ERROR_IO_ERROR"; + case STT_ERROR_INVALID_PARAMETER: + return "STT_ERROR_INVALID_PARAMETER"; + case STT_ERROR_TIMED_OUT: + return "STT_ERROR_TIMED_OUT"; + case STT_ERROR_RECORDER_BUSY: + return "STT_ERROR_RECORDER_BUSY"; + case STT_ERROR_OUT_OF_NETWORK: + return "STT_ERROR_OUT_OF_NETWORK"; + case STT_ERROR_PERMISSION_DENIED: + return "STT_ERROR_PERMISSION_DENIED"; + case STT_ERROR_NOT_SUPPORTED: + return "STT_ERROR_NOT_SUPPORTED"; + case STT_ERROR_INVALID_STATE: + return "STT_ERROR_INVALID_STATE"; + case STT_ERROR_INVALID_LANGUAGE: + return "STT_ERROR_INVALID_LANGUAGE"; + case STT_ERROR_ENGINE_NOT_FOUND: + return "STT_ERROR_ENGINE_NOT_FOUND"; + case STT_ERROR_OPERATION_FAILED: + return "STT_ERROR_OPERATION_FAILED"; + case STT_ERROR_NOT_SUPPORTED_FEATURE: + return "STT_ERROR_NOT_SUPPORTED_FEATURE"; + case STT_ERROR_RECORDING_TIMED_OUT: + return "STT_ERROR_RECORDING_TIMED_OUT"; + case STT_ERROR_NO_SPEECH: + return "STT_ERROR_NO_SPEECH"; + case STT_ERROR_IN_PROGRESS_TO_READY: + return "STT_ERROR_IN_PROGRESS_TO_READY"; + case STT_ERROR_IN_PROGRESS_TO_RECORDING: + return "STT_ERROR_IN_PROGRESS_TO_RECORDING"; + case STT_ERROR_IN_PROGRESS_TO_PROCESSING: + return "STT_ERROR_IN_PROGRESS_TO_PROCESSING"; + case STT_ERROR_SERVICE_RESET: + return "STT_ERROR_SERVICE_RESET"; + default: + return "STT_ERROR_" + std::to_string(error); + } +} + +std::string StateName(stt_state_e state) { + switch (state) { + case STT_STATE_CREATED: + return "created"; + case STT_STATE_READY: + return "ready"; + case STT_STATE_RECORDING: + return "recording"; + case STT_STATE_PROCESSING: + return "processing"; + default: + return "unknown"; + } +} + +std::string ResultEventName(stt_result_event_e event) { + switch (event) { + case STT_RESULT_EVENT_FINAL_RESULT: + return "final"; + case STT_RESULT_EVENT_PARTIAL_RESULT: + return "partial"; + case STT_RESULT_EVENT_ERROR: + return "error"; + default: + return "unknown"; + } +} + +EncodableMap ErrorMap(int error, const std::string& operation) { + return EncodableMap{ + {EncodableValue("operation"), EncodableValue(operation)}, + {EncodableValue("code"), EncodableValue(error)}, + {EncodableValue("name"), EncodableValue(ErrorName(error))}, + }; +} + +EncodableMap StateMap(stt_state_e state) { + return EncodableMap{ + {EncodableValue("code"), EncodableValue(static_cast(state))}, + {EncodableValue("name"), EncodableValue(StateName(state))}, + }; +} + +bool CollectLanguage(stt_h stt, const char* language, void* user_data) { + auto* languages = static_cast(user_data); + languages->push_back(EncodableValue(language ? language : "")); + return true; +} + +class TizenSttPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrar* registrar) { + auto plugin = std::make_unique(); + auto* plugin_pointer = plugin.get(); + + auto method_channel = + std::make_unique>( + registrar->messenger(), kMethodChannelName, + &flutter::StandardMethodCodec::GetInstance()); + method_channel->SetMethodCallHandler( + [plugin_pointer](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + plugin->method_channel_ = std::move(method_channel); + + auto event_channel = + std::make_unique>( + registrar->messenger(), kEventChannelName, + &flutter::StandardMethodCodec::GetInstance()); + auto handler = + std::make_unique>( + [plugin_pointer](const EncodableValue* arguments, + std::unique_ptr&& events) + -> std::unique_ptr { + plugin_pointer->event_sink_ = std::move(events); + return nullptr; + }, + [plugin_pointer](const EncodableValue* arguments) + -> std::unique_ptr { + plugin_pointer->event_sink_.reset(); + return nullptr; + }); + event_channel->SetStreamHandler(std::move(handler)); + plugin->event_channel_ = std::move(event_channel); + + registrar->AddPlugin(std::move(plugin)); + } + + ~TizenSttPlugin() override { + DisposeStt(); + ClearEvents(); + } + + private: + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const auto& method_name = method_call.method_name(); + if (method_name == "initialize") { + if (initialize_result_) { + ReplyError(std::move(result), STT_ERROR_IN_PROGRESS_TO_READY, + method_name); + return; + } + int ret = Initialize(); + stt_state_e state = STT_STATE_CREATED; + if (ret == STT_ERROR_NONE) ret = stt_get_state(stt_, &state); + if (ret != STT_ERROR_NONE) { + DisposeStt(); + ReplyError(std::move(result), ret, method_name); + } else if (state == STT_STATE_CREATED) { + initialize_result_ = std::move(result); + initialize_timer_id_ = g_timeout_add( + 10000, + [](gpointer data) -> gboolean { + auto* self = static_cast(data); + self->initialize_timer_id_ = 0; + self->CompleteInitialize(STT_ERROR_TIMED_OUT); + self->DisposeStt(); + return G_SOURCE_REMOVE; + }, + this); + } else { + ReplyStatus(std::move(result)); + } + return; + } + if (method_name == "getLanguages") { + EncodableList languages; + int ret = stt_ ? stt_foreach_supported_languages(stt_, CollectLanguage, + &languages) + : STT_ERROR_INVALID_STATE; + if (ret != STT_ERROR_NONE) { + ReplyError(std::move(result), ret, method_name); + } else { + result->Success(EncodableValue(languages)); + } + return; + } + if (method_name == "start" || method_name == "stop" || + method_name == "cancel") { + int ret = method_name == "start" ? Start(method_call.arguments()) + : method_name == "stop" ? Stop() + : Cancel(); + if (ret != STT_ERROR_NONE) { + ReplyError(std::move(result), ret, method_name); + } else { + ReplyStatus(std::move(result)); + } + return; + } + if (method_name == "dispose") { + int ret = DisposeStt(); + if (ret != STT_ERROR_NONE) { + ReplyError(std::move(result), ret, "stt_destroy"); + return; + } + result->Success(); + return; + } + if (method_name == "getState") { + ReplyStatus(std::move(result)); + return; + } + result->NotImplemented(); + } + + int EnsureHandle() { + if (stt_) { + return STT_ERROR_NONE; + } + + int ret = stt_create(&stt_); + if (ret != STT_ERROR_NONE) { + stt_ = nullptr; + return ret; + } + + ret = stt_set_state_changed_cb(stt_, OnStateChanged, this); + if (ret == STT_ERROR_NONE) + ret = stt_set_recognition_result_cb(stt_, OnRecognitionResult, this); + if (ret == STT_ERROR_NONE) ret = stt_set_error_cb(stt_, OnError, this); + if (ret != STT_ERROR_NONE) return ret; + return STT_ERROR_NONE; + } + + int Initialize() { + int ret = EnsureHandle(); + if (ret != STT_ERROR_NONE) { + return ret; + } + + stt_state_e state = STT_STATE_CREATED; + ret = stt_get_state(stt_, &state); + if (ret != STT_ERROR_NONE) { + return ret; + } + if (state == STT_STATE_CREATED) { + ret = stt_prepare(stt_); + if (ret != STT_ERROR_NONE) { + return ret; + } + } + return STT_ERROR_NONE; + } + + void CompleteInitialize(int error) { + if (initialize_timer_id_) { + g_source_remove(initialize_timer_id_); + initialize_timer_id_ = 0; + } + if (!initialize_result_) return; + auto result = std::move(initialize_result_); + if (error == STT_ERROR_NONE) { + ReplyStatus(std::move(result)); + } else { + ReplyError(std::move(result), error, "initialize"); + } + } + + int Start(const EncodableValue* arguments) { + if (!stt_) return STT_ERROR_INVALID_STATE; + if (!arguments || !std::holds_alternative(*arguments)) { + return STT_ERROR_INVALID_PARAMETER; + } + const auto& args = std::get(*arguments); + auto language = args.find(EncodableValue("language")); + auto type = args.find(EncodableValue("type")); + if (language == args.end() || type == args.end() || + (!std::holds_alternative(language->second) && + !std::holds_alternative(language->second)) || + !std::holds_alternative(type->second)) { + return STT_ERROR_INVALID_PARAMETER; + } + const auto* language_value = std::get_if(&language->second); + const auto& type_value = std::get(type->second); + if ((language_value && (language_value->empty() || + language_value->find('\0') != std::string::npos)) || + type_value.empty() || type_value.find('\0') != std::string::npos) { + return STT_ERROR_INVALID_PARAMETER; + } + return stt_start(stt_, language_value ? language_value->c_str() : nullptr, + type_value.c_str()); + } + + int Stop() { + if (!stt_) { + return STT_ERROR_INVALID_STATE; + } + return stt_stop(stt_); + } + + int Cancel() { + if (!stt_) return STT_ERROR_NONE; + if (initialize_result_) { + return DisposeStt(); + } + stt_state_e state; + int ret = stt_get_state(stt_, &state); + if (ret != STT_ERROR_NONE) return ret; + if (state == STT_STATE_CREATED || state == STT_STATE_READY) + return STT_ERROR_NONE; + return stt_cancel(stt_); + } + + int DisposeStt() { + CompleteInitialize(STT_ERROR_INVALID_STATE); + if (stt_) { + int ret = stt_destroy(stt_); + if (ret != STT_ERROR_NONE) return ret; + stt_ = nullptr; + } + ClearEvents(); + return STT_ERROR_NONE; + } + + void ReplyStatus( + std::unique_ptr> result) { + EncodableMap state_map{ + {EncodableValue("code"), EncodableValue(-1)}, + {EncodableValue("name"), EncodableValue("notCreated")}, + }; + if (stt_) { + stt_state_e state; + int ret = stt_get_state(stt_, &state); + if (ret != STT_ERROR_NONE) { + ReplyError(std::move(result), ret, "stt_get_state"); + return; + } + state_map = StateMap(state); + } + result->Success(EncodableValue(EncodableMap{ + {EncodableValue("state"), EncodableValue(state_map)}, + })); + } + + void ReplyError( + std::unique_ptr> result, + int error, const std::string& operation) { + result->Error(ErrorName(error), operation + " failed: " + ErrorName(error), + EncodableValue(ErrorMap(error, operation))); + } + + void EmitError(int error, const std::string& operation) { + EncodableMap map = ErrorMap(error, operation); + map[EncodableValue("type")] = EncodableValue("error"); + EmitEvent(map); + } + + void EmitEvent(EncodableMap map) { + std::lock_guard lock(queue_mutex_); + event_queue_.push(EncodableValue(std::move(map))); + if (!event_source_id_) { + event_source_id_ = g_idle_add_full( + G_PRIORITY_DEFAULT, + [](gpointer data) -> gboolean { + static_cast(data)->DrainEvents(); + return G_SOURCE_REMOVE; + }, + this, nullptr); + } + } + + void ClearEvents() { + std::lock_guard lock(queue_mutex_); + if (event_source_id_) { + g_source_remove(event_source_id_); + event_source_id_ = 0; + } + std::queue().swap(event_queue_); + } + + void DrainEvents() { + std::queue pending; + { + std::lock_guard lock(queue_mutex_); + event_source_id_ = 0; + std::swap(pending, event_queue_); + } + while (!pending.empty()) { + const auto& map = std::get(pending.front()); + const auto& type = std::get(map.at(EncodableValue("type"))); + if (initialize_result_ && type == "state" && + std::get(map.at(EncodableValue("current"))) + .at(EncodableValue("name")) == EncodableValue("ready")) { + CompleteInitialize(STT_ERROR_NONE); + } else if (initialize_result_ && type == "error") { + CompleteInitialize(std::get(map.at(EncodableValue("code")))); + DisposeStt(); + } + if (event_sink_) { + event_sink_->Success(pending.front()); + } + pending.pop(); + } + } + + static void OnStateChanged(stt_h stt, stt_state_e previous, + stt_state_e current, void* user_data) { + auto* self = static_cast(user_data); + self->EmitEvent(EncodableMap{ + {EncodableValue("type"), EncodableValue("state")}, + {EncodableValue("previous"), EncodableValue(StateMap(previous))}, + {EncodableValue("current"), EncodableValue(StateMap(current))}, + }); + } + + static void OnRecognitionResult(stt_h stt, stt_result_event_e event, + const char** data, int data_count, + const char* msg, void* user_data) { + auto* self = static_cast(user_data); + EncodableList texts; + for (int i = 0; i < data_count; ++i) { + texts.push_back(EncodableValue(data && data[i] ? data[i] : "")); + } + std::string text; + if (!texts.empty() && std::holds_alternative(texts.front())) { + text = std::get(texts.front()); + } + self->EmitEvent(EncodableMap{ + {EncodableValue("type"), EncodableValue("result")}, + {EncodableValue("event"), EncodableValue(ResultEventName(event))}, + {EncodableValue("text"), EncodableValue(text)}, + {EncodableValue("texts"), EncodableValue(texts)}, + {EncodableValue("message"), EncodableValue(msg ? msg : "")}, + }); + } + + static void OnError(stt_h stt, stt_error_e reason, void* user_data) { + auto* self = static_cast(user_data); + self->EmitError(reason, "stt_error_cb"); + } + + stt_h stt_ = nullptr; + guint initialize_timer_id_ = 0; + std::unique_ptr> initialize_result_; + guint event_source_id_ = 0; + std::mutex queue_mutex_; + std::queue event_queue_; + std::unique_ptr event_sink_; + std::unique_ptr> method_channel_; + std::unique_ptr> event_channel_; +}; + +} // namespace + +void TizenSttPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + TizenSttPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +}