20200222のiOSに関する記事は9件です。

「.swiftlint.yml」のルールが機能しなくハマったこと

.swiftlint.ymlにline_lengthのルールを記載したのに反映されないことがありハマりました。
AppDelegateなどでコメントのwarningがでていたのでLintに以下のルールを追加しましたが、反映されませんでした。

.swiftlint.yml
line_length:
  warning: 300
  ignores_comments: true

SwiftLintのバージョンが古いとか、RULEに半角スペースが3つあるとかでも反映されないような現象があるみたいですが、
私の場合は違いました。

試したこと

  • Homebrewをupgradeしてswiftlintの入れ直し。
  • YAMLが正しく書いているか確認。
  • swiftlintのupgrade / swiftlintのuninstall

結論

.swiftlint.ymlxxx.xcodeprojと同じ階層にないと反映されない。
私の場合、githubからcloneしてきたので以下のディレクトリの構成になっていました。

before
SampleProject
    ├── SampleProject
    │   ├── SampleProject/...
    │   ├── SampleProject.xcodeproj
    │   └── SampleProjectTests
    ├── .swiftlint.yml
    ├── README.md
    ├── .gitignore
    └── .git

以下のようにルートディレクトリにxxx.xcode.proj.swiftlint.ymlを同じ階層に置けばルールが適応されました。

after
SampleProject
    ├── SampleProject/...
    ├── SampleProject.xcodeproj
    ├── SampleProjectTests
    ├── .swiftlint.yml
    ├── README.md
    ├── .gitignore
    └── .git

cloneしてからプロジェクトファイルを作るとプロジェクト名のディレクトリがダブるので、
プロジェクトファイルを作成してから、git clone http://xxxxxx.com/xxxxxx.git .をした方がよさそうと思った。
最後の.はリポジトリと同名のディレクトリを作らないためにつける。

参考

How to use swiftlint command and Xcode Script when .swiftlint.yml file not in the same folder with project.xcodeproj? · Issue #2673 · realm/SwiftLint
https://github.com/realm/SwiftLint/issues/2673

git clone でディレクトリを作らない | 株式会社Orfool
https://orfool.com/programing/2206/

SwiftLintを試してみた - Qiita
https://qiita.com/ushisantoasobu/items/b494c9cf7d78a968b373

SwiftLintのdisabled_rules一覧(実例付き) - Qiita
https://qiita.com/akatsuki174/items/6da27f7ccdd0572f8927

SwiftLintの運用ノウハウ - Qiita
https://qiita.com/shtnkgm/items/6dd756aa14926736c6f5

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Flutter Form系Widgetの使い方 〜 すべてのWidgetを学習

はじめに

自身の勉強を兼ねて、Widget Catalogで公開されている全てのWidgetについての解説やソースコードを交えた使い方などをまとめています。

基本方針として、掲載しているサンプルコードは公式サイトのサンプルを流用しています。しかし、公式サイトにはサンプルコードがないものが多くあるため、その場合には動作確認済みの自身のコードを掲載しています。間違い等ありましたらご指摘頂けると助かります。

カテゴリ Widgetの種類 Qiitaまとめ記事
Accesibility アクセシビリティに関するWidget Coming Soon
Animation and Motion アニメーションに関するWidget Coming Soon
Assets, Images, and Icons Asset/Icon/画像表示に関するWidget Coming Soon
Async Asyncパターン (FutureBuilder, StreamBuilder) Coming Soon
Basics 最低限抑えるべき基本的なWidget Coming Soon
Cupertino (iOS-style widgets) iOS風の各種Widget Coming Soon
Input フォームやキー入力等の入力に関するWidget 今回
Interaction Models タッチイベントやその他操作の応答に関するWidget Coming Soon
Layout レイアウトに関するWidget Coming Soon
Material Components Android マテリアルデザインの各種Widget Coming Soon
Painting and effects ペイント, エフェクト効果等のWidget Coming Soon
Scrolling GridView等のスクロール機能を保有するようなWidget Coming Soon
Styling テーマ等のスタイリングに関わるWidget Coming Soon
Text テキスト表示に関わるWidget Flutter Text Widgetの使い方 〜 すべてのWidgetを学習

Form系 Widgets

テキスト入力用途のフォームやキー入力検出に関する以下のWidgetについて解説します。

Widget名 用途
Form FormField用のコンテナWidgetで複数のFormFieldをまとめて管理する時に利用します。
TextFormField テキスト入力のFormField Widgetです。
FormField Form内で利用するベースとなるWidgetです。これを直接利用することはなく、TextFormFieldのように、FormFieldを継承して独自のFormFieldを作成する時に利用します。
RawKeyboardListener キー入力検出用のリスナーです。

Form

Formクラス仕様

後述の複数のFormFiled Widgetをグループ化して管理するためのコンテナWidgetです。グループ化することで、ユーザが入力データの送信ボタンを押した時に、フォーム内の各入力データの形式チェックするValidatorを一括して呼び出すことが出来ます。

Formクラス
const Form({
  Key key,
  @required this.child,
  this.autovalidate = false,
  this.onWillPop,
  this.onChanged,
}) : assert(child != null),
     super(key: key);

Formは子クラス (後述のTextFormFiledなど) の最上位のコンテナとして定義します。

なお、Validatorを利用する場合には、第一引数のKeyGlobalKey<FormState>を指定する必要があります。このKeyについては別の記事にて解説予定のため、ここでは以下のサンプルコードのように必要なものだレベルで覚えてるに留めておいてください。

TextFormField

TextFormFieldクラス仕様

別途解説予定のTextField Widgetを後述のFormFieldに適用したWidgetです。つまり、FormにおけるText入力のWidgetです。

サンプルコード

サンプルとして、2つのテキスト入力を持ったフォームの例を示します。Submitボタンを押した時に各テキスト入力をチェックし、空白以外ならOKというロジックになっています。
ezgif.com-video-to-gif-2.gif

サンプルコード
class FormSampleState extends State<WidgetSample> {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          TextFormField(
            decoration: const InputDecoration(
              labelText: "Email address form", // ラベル
              hintText: 'Enter your email', // 入力ヒント
            ),
            autovalidate: false, // 入力変化しても自動でチェックしない。trueにすると初期状態および入力が変化する毎に自動でvalidatorがコールされる
            validator: (value) { // _formKey.currentState.validate()でコールされる
              if (value.isEmpty) {
                return 'Please enter some text'; // エラー表示のメッセージを返す
              }
              return null; // 問題ない場合はnullを返す
            },
            onSaved: (value) => () { // this._formKey.currentState.save()でコールされる
              print('$value');
            },
          ),
          TextFormField(
            decoration: const InputDecoration(
              icon: Icon(Icons.email),
              border: OutlineInputBorder(), // 外枠付きデザイン
              filled: true, // fillColorで指定した色で塗り潰し
              fillColor: Colors.greenAccent,
              labelText: "Email address form",
              hintText: 'Enter your email',
            ),
            autovalidate: false,
            validator: (value) {
              if (value.isEmpty) {
                return 'Please enter some text';
              }
              return null;
            },
          ),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 16.0),
            child: RaisedButton(
              onPressed: () {
                // 各Fieldのvalidatorを呼び出す
                if (_formKey.currentState.validate()) {
                  // 入力データが正常な場合の処理
                  this._formKey.currentState.save();
                }
              },
              child: Text('Submit'),
            ),
          ),
        ],
      ),
    );
  }
}

FormField

FormFieldクラス仕様

FormFieldForm内で利用するベースとなるWidgetです。後述のTextFormFieldもこれを継承しており、その他Form内で利用する独自定義Widgetを作成するなどに利用します。

サンプル

サンプルとして、独自定義Widgetを利用したフォーム入力の例を示します。初期値0のカウンタを+/-ボタンで増減するフォームを作成しています。Submitボタンを押した時に-1以外であればResult表示を更新し、それ以外はFieldにエラーメッセージが表示されます。
ezgif.com-video-to-gif.gif

サンプルコード
class FormFieldSampleState extends State<WidgetSample> {
  final _formKey = GlobalKey<FormState>();
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Form(
        key: _formKey,
        child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              CounterFormField( // 独自定義のFormField
                autovalidate: false,
                validator: (value) {
                  if (value < 0) return 'Negative values not supported';
                  return null;
                },
                onSaved: (value) => setState(() {
                  _count = value;
                }),
              ),
              FlatButton(
                child: Text('Submit'),
                color: Colors.blue,
                onPressed: () {
                  if (this._formKey.currentState.validate()) {
                    this._formKey.currentState.save();
                  }
                },
              ),
              SizedBox(height: 20.0),
              Text('Result = $_count')
            ]));
  }
}

// int型のデータを保有する独自FormLieldクラスを作成
class CounterFormField extends FormField<int> {
  CounterFormField(
      {FormFieldSetter<int> onSaved,
      FormFieldValidator<int> validator,
      int initialValue = 0,
      bool autovalidate = false})
      : super(
            onSaved: onSaved,
            validator: validator,
            initialValue: initialValue,
            autovalidate: autovalidate,
            builder: (FormFieldState<int> state) {
              return Column(children: <Widget>[
                Row(
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    IconButton(
                      icon: Icon(Icons.remove),
                      onPressed: () {
                        // Fieldの値を変化させる
                        state.didChange(state.value - 1);
                      },
                    ),
                    Text(state.value.toString()),
                    IconButton(
                      icon: Icon(Icons.add),
                      onPressed: () {
                        state.didChange(state.value + 1);
                      },
                    ),
                  ],
                ),
                state.hasError // Validatorの結果がエラー時の表示をここで対応
                    ? Text(
                        state.errorText,
                        style: TextStyle(color: Colors.red),
                      )
                    : Container(),
              ]);
            });
}

RawKeyboardListener

RawKeyboardListenerクラス仕様

キーボード入力イベントをキャッチするためのWidgetです。iOS (かつエミュレータのみ?) 以外は問題なく動いてそうですが、iOS (かつエミュレータのみ?) だと反応が無いです。2016年の時点でissueが出ていますね。。
RawKeyboardListener doesn't receive any events from keyboard in ios simulator

サンプル

キーイベントを検出したら表示しているだけのサンプルです。
ezgif.com-video-to-gif-2.gif

サンプルコード
class RawKeyboardListenerSampleState extends State<WidgetSample> {
  final FocusNode _focusNode = FocusNode();
  String _message;

  @override
  void dispose() {
    _focusNode.dispose();
    super.dispose();
  }

  void _handleKeyEvent(RawKeyEvent event) {
    setState(() {
      _message =
          'Key: ${event.logicalKey.debugName}, KeyId: ${event.logicalKey.keyId}';
    });
  }

  @override
  Widget build(BuildContext context) {
    return RawKeyboardListener(
        focusNode: _focusNode,
        onKey: _handleKeyEvent,
        child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              TextField(),
              Text(_message ?? 'Press a key'),
            ]));
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS と Android で Opus 形式のオーディオを再生しようとしたらコンテナの違いにはまった話し

Android と iOS で Opus を扱うにあたって チョットワカッタ -> 完全に理解した(慢心)を20ループくらいしたので知見をメモします。

やりたかったこと

.wav(の中のPCM)音源を品質と圧縮率のバランス良い形のフォーマットに変換して、Android (android.media.MediaPlayer) と iOS (AVKit) からよしなに再生したい。オーディオファイルのフォーマットとしては Opus が良さそうでこれに目を付ける。

結論

Android 向けには Matroskaコンテナ、iOS 向けには CAFコンテナの中に libopus で圧縮した Opus 形式の音声データを格納すればよい。1ファイルで両プラットフォーム対応は無理なので諦めよう。

FFmpeg を使う場合以下のようになる。

# for Android
ffmpeg -i input.wav -vn -ac 2 -c:a libopus -b:a 24k output.mkv

# for iOS
ffmpeg -i input.wav -vn -ac 2 -c:a libopus -b:a 24k output.caf

input.wav が 140MB 程度で、エンコードしたあとは概ね 1MB程度。(上記コマンドのとおり ビットレートを 24k に指定した場合)

何が大変だったのか

Android は公式に Opus コーデック(のエンコード)をサポートしているが1、コンテナが適切でないと再生はできるけどシークが適切に行えない、などの問題があった。ローカルファイルだと行えてストリーミングだと無理、などの場合もあり問題の切り分けに苦労しつつ試行錯誤の末コンテナを Matroska にする必要がありそうなことに気付く(というか公式ドキュメントに書いてあった)。この間 .ogg, .oga, libopus, libvorbis などの組み合わせを無限に総当たりで試し続ける。

iOS は iOS11 から Opus コーデックをサポートするという話しがあったが結局どうなってしまったのかよくわかっていない。Androidでいけた .mkv ファイルがうまく再生できず詰んだ!ここは旧世代の遺物AACか〜 からの、afconvert コマンドで適当に .caf ファイルを作ってみたらなぜか再生できることに気づき、コンテナを CAF にする必要がありそうなことに気付く。

ちなみに Opus と CAF については Wikipedia - Opus を見ると (Limited container support) という表記とともに On iOS 11:Core Audio Format (.caf) と書いてあるのでつまりそういうことなんだろう。ということはこの記事を書いていて気付きました。アウトプット大事...。

何で Opus ?

音楽ファイルが96kbpsになる日――Opus音声コーデックの実力 - Qiita とか、YouTube で Opus vs MP3 で検索した結果 とかを参照すると良いと思います。感覚的な表現をすると、低ビットレートなMP3における音のこもり感が劇的に解消されている感じ。劇的に。これにより高品質で高圧縮なオーディオファイルをつくることができる。特に最新の技術ではない。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AirDropは使えるのにContinuityが使えない時の解決策

問題

AirDropは使えるのに、Continuityが使えない。

Continuityとは、iOSとmacOSの連携機能のこと。
詳しくはこちら。
https://support.apple.com/ja-jp/HT204681

AppleIDのログインし直し、再起動、何度試しても解決しない。
ハードウェア的に対応していないわけではない。

……ということがあったので、自分の場合の解決策を載せます。

解決策

Wi-FiとEthernetの接続を片方ずつにしてみる

据置PCだったのでWi-FiとEthernet両方に繋いでいたのですが、Ethernetの方に問題があった模様。
一旦Ethernetを無効にしてWi-Fiのみにしたら、見事Continuityが使えるようになりました。

一度アクティベートされると、繋ぎ直しても問題なさそうで、自分は優先順位をWi-Fi > Ethernetにしていますが、今のところ問題ないです。

スクリーンショット 2020-02-22 18.36.38.png

というだけのお話

実はこれ、Appleサポートの方に教えていただきました。
Continuityは2段階認証やら無線モジュールやらの制約が厳しいので原因が分かりづらいのですが、まさかネットワークの問題だったとは。
どこのサイトにも載っていなかったので、忘備録として記事にしました。

……にしても最近、技術的な記事書いてないなぁ。。。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Apple] 写経 Start Developing iOS Apps (Swift)

Apple公式ドキュメント Start Developing iOS Apps (Swift) の一部を写経します。

appデリゲートソースファイル

AppDelegate.swiftには、2つの主要な関数があります。

  • AppDelegateクラスを定義します。appデリゲートは、コンテンツが描画されるウィンドウを作成し、アプリケーション内の状態遷移を受け付けるための場所を提供します。
  • アプリケーションのエントリポイントと、入力イベントをアプリケーションへ渡す実行ループを作成します。これはファイル先頭にあるUIApplicationMain属性(@UIApplicationMain)によって行われます。 UIApplicationMain属性は、デリゲートクラスとしてAppDelegateクラスをUIApplicationMainへ渡すことと同義です。戻り値の中で、システムはアプリケーションオブジェクトを作成します。アプリケーションオブジェクトはアプリケーションのライフサイクルを管理する責任を負います。システムはAppDelegateクラスのインスタンスもまた作成しアプリケーションオブジェクトへ割り当てます。最終的にシステムはアプリケーションを起動します。

わかったこと:
アプリケーションの実行時に、アプリケーションオブジェクトとappデリゲートが作成される。
UIApplicationMain属性をつけることで、アプリケーションのエントリポイントとappデリゲートであることをシステムに認識させる。

AppDelegateクラスはプロパティを一つ含みます。

var window: UIWindow?

このプロパティはアプリケーションのウィンドウへの参照を格納しています。ウィンドウはアプリケーションのビュー階層の根元です。コンテンツはこの中に描画されます。このwindowプロパティはオプショナルであることに注意が必要です。ある時点では値が入っていません(nilです)。

AppDelegateクラスは以下のメソッドのスタブ実装も含んでいます。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool
func applicationWillResignActive(_ application: UIApplication)
func applicationDidEnterBackground(_ application: UIApplication)
func applicationWillEnterForeground(_ application: UIApplication)
func applicationDidBecomeActive(_ application: UIApplication)
func applicationWillTerminate(_ application: UIApplication)

これらのメソッドは、アプリケーションオブジェクトにappデリゲートとやりとりさせるためのものです。

いずれのデリゲートメソッドも既定の振る舞いを持っています。メソッドが空か存在しないと、既定の振る舞いをします。

わかったこと:
AppDelegateプロトコルを実装することでアプリケーションオブジェクトとやりとりする。
AppDelegateプロトコルの中身を実装しないと、既定の振る舞いをする。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Amazon TranscribeとSwiftから連携してみた

Amazon Transcribeとは

音声ファイルから自動的に文字を起こしてくれるAmazonのサービスです。
https://aws.amazon.com/jp/transcribe/
類似のものに、Google CloudのSpeech-to-Textがあります。
https://cloud.google.com/speech-to-text?hl=ja

今回説明しないもの

・AWSの設定の詳細
・S3へのアップロード、ダウンロードの詳細
・動画や音声の詳細

準備

AWS

Cognito
iOSアプリからAWSにアクセスするためのやつ。
S3
Transcribeに使う音声ファイルやjobファイルを置くストレージ。
Transcribe
本丸。Create Jobから特に困ることなく設定可能です。

iOS

AWS SDK for iOS
AWSS3AWSTranscribeだけあれば大丈夫です。(AWSCoreもついてくるので。)

おおまかな流れ

iOSから音声(動画)ファイルをS3にアップロード
-> iOSからTranscribeのjobを実行命令
-> AWSのTranscribeがjobを実行し、先ほどアップロードしたS3のファイルを文字化
-> Transcribeの結果取得
-> 変換結果のjsonファイルをS3からダウンロード

実装

初期設定

SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // AWS Cognito & S3 & Transcribe registration
    let credentialProvider = AWSCognitoCredentialsProvider(regionType: .APNortheast1, identityPoolId: "ap-northeast-1:*******************")
    if let configuration = AWSServiceConfiguration(region:.APNortheast1, credentialsProvider:credentialProvider) {
        AWSS3TransferUtility.register(with:configuration!, forKey: "MY_S3")
        AWSTranscribe.register(with:configuration!, forKey: "MY_Transcribe")
    }
}

SceneDelegate(AppDelegate)のおなじみの起動ファンクションでAWS初期設定します。
Cognitoでregionと設定の際に発行されたIdentity Pool IDを設定します。
設定後、Transcribe、 AWSS3TransferUtilityへの登録も行っておきます。

S3へ該当ファイルをアップロード

let bucketName = "mybucket"
let mp4URL = URL(fileURLWithPath: "your/video.mp4")
if let awss3 = AWSS3TransferUtility.s3TransferUtility(forKey: "MY_S3") {
    do {
        let videoData = try Data(contentsOf: mp4URL)
        let videoName = "s3video.mp4"
        awss3.uploadData(
            videoData,
            bucket: bucketName,
            key: videoName,
            contentType: "mp4",
            expression: nil, // 途中経過task nullable
            completionHandler: { task, error in
                if let error = error {
                    print("s3 upload error \(error)")
                } else {
                    print("success upload")
                }
    } catch let error {
        print("data convert error \(error)")
    }
}

今回はmp4ファイルで行います。Transcribeがフォローしているファイルタイプは、flac, mp3, mp4, wavの4タイプです。(2020/2/20現在)
録画なりして端末に入ってるmp4ファイルをData型で取得し、AWSS3TransferUtility.uploadDataでアップロードします。
ここでのbucketはCognitoで設定したものと同じでなくてはなりません。
expressionはアップロードの途中経過を取得できるtaskですが今回は省きます。
また、uploadData自体もTaskとして登録できるものもあるので使い分けてください。

TranscribeのJobを実行

let awstrans = AWSTranscribe(forKey: "MY_Transcribe")
let jobName = "MY_JOB"
if let startRequest = AWSTranscribeStartTranscriptionJobRequest() {
    startRequest.languageCode = .jaJP // language code結構いっぱいある
    let media = AWSTranscribeMedia()
    media?.mediaFileUri = "https://s3-ap-northeast-1.amazonaws.com/\(bucketName)/\(videoName)"
    startRequest.media = media // 先ほどアップロードしたs3のファイルのurlを指定する
    startRequest.mediaFormat = .mp4 // flac, mp3, mp4, wav
    startRequest.mediaSampleRateHertz = 44100 // いらないかも
    startRequest.transcriptionJobName = jobName
    startRequest.outputBucketName = bucketName

    // Job実行
    awstrans.startTranscriptionJob(startRequest, completionHandler: {response, error in
        if let error = error {
            print("start job error \(error)")
        } else {
            print("success start job")
        }
    }
}

AWSTranscribeStartTranscriptionJobRequestでJobのステータスを設定します。
ここで勘違いしていたのが、startTranscriptionJobのcompletionがJob完了時に呼ばれるものだと思ってましたが、これはあくまでJobがスタートした時に呼ばれるものでした。

TranscribeのJobが完了するまで待って取得

// timer使うので
DispatchQueue.main.async {
    self.timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true, block: { timer in
        if let getJobRequest = AWSTranscribeGetTranscriptionJobRequest() {
            getJobRequest.transcriptionJobName = projectId
            awstrans.getTranscriptionJob(getJobRequest, completionHandler: {response, error in
                if let error = error {
                    print("get job error \(error)")
                    self.timer.invalidate()
                }
                if let reason = response?.transcriptionJob?.failureReason {
                    print("job failed \(reason)")
                }
                if response?.transcriptionJob?.transcriptionJobStatus == .completed {
                    // 完了後、awsにアップロードされた、結果の記載されたjsonのuriが取得できる
                    print(response?.transcriptionJob?.transcript?.transcriptFileUri)
                    self.timer.invalidate()
                }
            })
        }
    })
}

探してみたところ、Jobの完了通知をしてくれるものは見当たらなかったので、取り急ぎTimerで完了するまでgetし続けるという原始的なことをしました。しかも、mp4ファイルだと3MBぐらいのサイズでも40秒とかかかったので、interval=10としました。
JobStatus=completeとなった段階で、transcriptionJobにいろいろな値がセットされて返却されるので、結果の記載されたjsonのURIを取得する。
余談ですが、ハンドラ内でTimerを実行する際は実行スレッドに注意。

S3からTranscribeの結果jsonを取得

awss3.downloadData(
    fromBucket: bucketName,
    key: projectId + ".json",
    expression: nil,
    completionHandler:{task, location, data, error in
        if let error = error {
            print("s3 download error \(error)")
        } else {
            if let data = data {
                do {
                    let jsonDecoder = JSONDecoder()
                    let transcribeData = try jsonDecoder.decode(AmazonTranscribe.self, from: data)
                    print(transcribeData.results.transcripts)
                } catch let error {
                    print("decode error \(error)")
                }
            }
        }
    }
)
AmazonTranscribe.swift
struct AmazonTranscribe: Decodable {
    let jobName:String
    let accountId:String
    let results: AmazonTranscribeResults
    let status: String
}

struct AmazonTranscribeResults: Decodable {
    let transcripts: [AmazonTranscribeTranscripts]
    let items: [AmazonTranscribeItem]
}

struct AmazonTranscribeTranscripts: Decodable {
    let transcript: String // 全文
}

struct AmazonTranscribeItem: Decodable {
    let startTime: Double
    let endTime: Double
    let alternatives: [AmazonTranscribeAlternatives]
    let type: String

    private enum CodingKeys: String, CodingKey {
        case startTime = "start_time"
        case endTime = "end_time"
        case alternatives, type
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        guard let startTimeDouble = Double(try values.decode(String.self, forKey: .startTime)) else {
            fatalError("The start time is not an Double")
        }
        guard let endTimeDouble = Double(try values.decode(String.self, forKey: .endTime)) else {
            fatalError("The end time is not an Double")
        }

        startTime = startTimeDouble
        endTime = endTimeDouble
        alternatives = try values.decode([AmazonTranscribeAlternatives].self, forKey: .alternatives)
        type = try values.decode(String.self, forKey: .type)
    }
}

struct AmazonTranscribeAlternatives: Decodable {
    let confidence: Double
    let content: String

    private enum CodingKeys: String, CodingKey {
        case confidence, content
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        guard let confidenceDouble = Double(try values.decode(String.self, forKey: .confidence)) else {
            fatalError("The confidence is not an Double")
        }

        confidence = confidenceDouble
        content = try values.decode(String.self, forKey: .content)
    }
}

おまけでjsonをDecodeするのにつかったDecodableも載せておきます。返却値はこんな(AmazonTranscribe.swift)感じです。
各値の意味は深く調べてませんが、全文(なぜリスト?)と代替候補っぽいのはありました。

感想

正直なところ、日本語の文字起こし精度はさほど高くないように感じられました。
ファイルの形式等を調整したらもうちょい精度あがりそうですが、
試しにGoogleHomeとの、「ねぇGoogle、明日の天気は?」「明日の新宿は最高気温15度、最低気温7度で晴れるでしょう」というやりとり動画を送信してみたら、
「めぐる 明日 の 天気 は 明日 の 新宿 は 最高 気温 十 五 度 再 激 音 など で 買える でしょ」
というアウトプットがきました。この場合の使える情報は、明日の新宿が最高気温15度ってことだけでしょう。
英語はしゃべれないし、発音も悪いので試してません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Amazon TranscribeにSwiftアプリから連携してみた

Amazon Transcribeとは

音声ファイルから自動的に文字を起こしてくれるAmazonのサービスです。
https://aws.amazon.com/jp/transcribe/
類似のものに、Google CloudのSpeech-to-Textがあります。
https://cloud.google.com/speech-to-text?hl=ja

今回説明しないもの

・AWSの設定の詳細
・S3へのアップロード、ダウンロードの詳細
・動画や音声の詳細

準備

AWS

Cognito
iOSアプリからAWSにアクセスするためのやつ。
S3
Transcribeに使う音声ファイルやjobファイルを置くストレージ。
Transcribe
本丸。Create Jobから特に困ることなく設定可能です。

iOS

AWS SDK for iOS
AWSS3AWSTranscribeだけあれば大丈夫です。(AWSCoreもついてくるので。)

おおまかな流れ

iOSから音声(動画)ファイルをS3にアップロード
-> iOSからTranscribeのjobを実行命令
-> AWSのTranscribeがjobを実行し、先ほどアップロードしたS3のファイルを文字化
-> Transcribeの結果取得
-> 変換結果のjsonファイルをS3からダウンロード

実装

初期設定

SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // AWS Cognito & S3 & Transcribe registration
    let credentialProvider = AWSCognitoCredentialsProvider(regionType: .APNortheast1, identityPoolId: "ap-northeast-1:*******************")
    if let configuration = AWSServiceConfiguration(region:.APNortheast1, credentialsProvider:credentialProvider) {
        AWSS3TransferUtility.register(with:configuration!, forKey: "MY_S3")
        AWSTranscribe.register(with:configuration!, forKey: "MY_Transcribe")
    }
}

SceneDelegate(AppDelegate)のおなじみの起動ファンクションでAWS初期設定します。
Cognitoでregionと設定の際に発行されたIdentity Pool IDを設定します。
設定後、Transcribe、 AWSS3TransferUtilityへの登録も行っておきます。

S3へ該当ファイルをアップロード

let bucketName = "mybucket"
let mp4URL = URL(fileURLWithPath: "your/video.mp4")
if let awss3 = AWSS3TransferUtility.s3TransferUtility(forKey: "MY_S3") {
    do {
        let videoData = try Data(contentsOf: mp4URL)
        let videoName = "s3video.mp4"
        awss3.uploadData(
            videoData,
            bucket: bucketName,
            key: videoName,
            contentType: "mp4",
            expression: nil, // 途中経過task nullable
            completionHandler: { task, error in
                if let error = error {
                    print("s3 upload error \(error)")
                } else {
                    print("success upload")
                }
    } catch let error {
        print("data convert error \(error)")
    }
}

今回はmp4ファイルで行います。Transcribeがフォローしているファイルタイプは、flac, mp3, mp4, wavの4タイプです。(2020/2/20現在)
録画なりして端末に入ってるmp4ファイルをData型で取得し、AWSS3TransferUtility.uploadDataでアップロードします。
ここでのbucketはCognitoで設定したものと同じでなくてはなりません。
expressionはアップロードの途中経過を取得できるtaskですが今回は省きます。
また、uploadData自体もTaskとして登録できるものもあるので使い分けてください。

TranscribeのJobを実行

let awstrans = AWSTranscribe(forKey: "MY_Transcribe")
let jobName = "MY_JOB"
if let startRequest = AWSTranscribeStartTranscriptionJobRequest() {
    startRequest.languageCode = .jaJP // language code結構いっぱいある
    let media = AWSTranscribeMedia()
    media?.mediaFileUri = "https://s3-ap-northeast-1.amazonaws.com/\(bucketName)/\(videoName)"
    startRequest.media = media // 先ほどアップロードしたs3のファイルのurlを指定する
    startRequest.mediaFormat = .mp4 // flac, mp3, mp4, wav
    startRequest.mediaSampleRateHertz = 44100 // いらないかも
    startRequest.transcriptionJobName = jobName
    startRequest.outputBucketName = bucketName

    // Job実行
    awstrans.startTranscriptionJob(startRequest, completionHandler: {response, error in
        if let error = error {
            print("start job error \(error)")
        } else {
            print("success start job")
        }
    }
}

AWSTranscribeStartTranscriptionJobRequestでJobのステータスを設定します。
ここで勘違いしていたのが、startTranscriptionJobのcompletionがJob完了時に呼ばれるものだと思ってましたが、これはあくまでJobがスタートした時に呼ばれるものでした。

TranscribeのJobが完了するまで待って取得

// timer使うので
DispatchQueue.main.async {
    self.timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true, block: { timer in
        if let getJobRequest = AWSTranscribeGetTranscriptionJobRequest() {
            getJobRequest.transcriptionJobName = projectId
            awstrans.getTranscriptionJob(getJobRequest, completionHandler: {response, error in
                if let error = error {
                    print("get job error \(error)")
                    self.timer.invalidate()
                }
                if let reason = response?.transcriptionJob?.failureReason {
                    print("job failed \(reason)")
                }
                if response?.transcriptionJob?.transcriptionJobStatus == .completed {
                    // 完了後、awsにアップロードされた、結果の記載されたjsonのuriが取得できる
                    print(response?.transcriptionJob?.transcript?.transcriptFileUri)
                    self.timer.invalidate()
                }
            })
        }
    })
}

探してみたところ、Jobの完了通知をしてくれるものは見当たらなかったので、取り急ぎTimerで完了するまでgetし続けるという原始的なことをしました。しかも、mp4ファイルだと3MBぐらいのサイズでも40秒とかかかったので、interval=10としました。
JobStatus=completeとなった段階で、transcriptionJobにいろいろな値がセットされて返却されるので、結果の記載されたjsonのURIを取得する。
余談ですが、ハンドラ内でTimerを実行する際は実行スレッドに注意。

S3からTranscribeの結果jsonを取得

awss3.downloadData(
    fromBucket: bucketName,
    key: projectId + ".json",
    expression: nil,
    completionHandler:{task, location, data, error in
        if let error = error {
            print("s3 download error \(error)")
        } else {
            if let data = data {
                do {
                    let jsonDecoder = JSONDecoder()
                    let transcribeData = try jsonDecoder.decode(AmazonTranscribe.self, from: data)
                    print(transcribeData.results.transcripts)
                } catch let error {
                    print("decode error \(error)")
                }
            }
        }
    }
)
AmazonTranscribe.swift
struct AmazonTranscribe: Decodable {
    let jobName:String
    let accountId:String
    let results: AmazonTranscribeResults
    let status: String
}

struct AmazonTranscribeResults: Decodable {
    let transcripts: [AmazonTranscribeTranscripts]
    let items: [AmazonTranscribeItem]
}

struct AmazonTranscribeTranscripts: Decodable {
    let transcript: String // 全文
}

struct AmazonTranscribeItem: Decodable {
    let startTime: Double
    let endTime: Double
    let alternatives: [AmazonTranscribeAlternatives]
    let type: String

    private enum CodingKeys: String, CodingKey {
        case startTime = "start_time"
        case endTime = "end_time"
        case alternatives, type
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        guard let startTimeDouble = Double(try values.decode(String.self, forKey: .startTime)) else {
            fatalError("The start time is not an Double")
        }
        guard let endTimeDouble = Double(try values.decode(String.self, forKey: .endTime)) else {
            fatalError("The end time is not an Double")
        }

        startTime = startTimeDouble
        endTime = endTimeDouble
        alternatives = try values.decode([AmazonTranscribeAlternatives].self, forKey: .alternatives)
        type = try values.decode(String.self, forKey: .type)
    }
}

struct AmazonTranscribeAlternatives: Decodable {
    let confidence: Double
    let content: String

    private enum CodingKeys: String, CodingKey {
        case confidence, content
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        guard let confidenceDouble = Double(try values.decode(String.self, forKey: .confidence)) else {
            fatalError("The confidence is not an Double")
        }

        confidence = confidenceDouble
        content = try values.decode(String.self, forKey: .content)
    }
}

おまけでjsonをDecodeするのにつかったDecodableも載せておきます。返却値はこんな(AmazonTranscribe.swift)感じです。
各値の意味は深く調べてませんが、全文(なぜリスト?)と代替候補っぽいのはありました。

感想

正直なところ、日本語の文字起こし精度はさほど高くないように感じられました。
ファイルの形式等を調整したらもうちょい精度あがりそうですが、
試しにGoogleHomeとの、「ねぇGoogle、明日の天気は?」「明日の新宿は最高気温15度、最低気温7度で晴れるでしょう」というやりとり動画を送信してみたら、
「めぐる 明日 の 天気 は 明日 の 新宿 は 最高 気温 十 五 度 再 激 音 など で 買える でしょ」
というアウトプットがきました。この場合の使える情報は、明日の新宿が最高気温15度ってことだけでしょう。
英語はしゃべれないし、発音も悪いので試してません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CombineのflatMapで実行数を制御する

この文章の結論として、例えばWeb APIによる通信処理でPOST/DELETEやファイルアクセスによる作成/削除などでflatMapを使う際に、引数として.flatMap(maxPublishers: .max(1))を利用し、バックプレッシャーでリクエスト数を1として利用するほうが良さそうなんじゃないか?という文章です。

もし、「そりゃ違うよ」などのご意見があればコメントで頂けると幸いです。

はじめに

RxSwiftではflatMapLatestを使ったが、CombineではflatMapLatestがない。つまり連続するストリームのイベントに対してなるべく無駄なく新しいストリームを作りたい場合に、オペレータで制御する方法がないのかなと思っていました。

しかし、CombineのflatMapはパラメータにmaxPublishers: Subscribers.Demandがあり、リファレンスではThe maximum number of publishers produced by this methodとあります。これを試してみるとflatMapのmaxの数より多くの新しいストリームはつくられません。つまりflatMapLatestの完全な代用にはならないけれど使えてむしろflatMapLatestより良い部分もあるんじゃないかと思ってます。

(まあdisposeの仕組みでキャンセルとかflatMapLatestのほうが良いこともありそうだけど)。

あとSwiftUI ガイドブック発売中です。

検証したいこと

言葉で説明しても分からないと思うので、そのflatMapする際の検証コードを載せておきます。

  • 検証1
    • やること
      • 1...3でイベントを流してflatMapで非同期のイベントを流してみる
    • 検証コード1
      • flatMapを.unlimitedにして実行を観測
    • 検証コード2
      • .max(1)にして実行を観測
  • 検証2
    • やること
      • .unlimitedでは上流自体が止められていないことを確かめたいのでエラー流す
      • 非同期ではないほうがわかりやすいはず
    • 検証コード1
      • flatMapを.unlimitedにして実行を観測

検証1:

検証コードの仕様

  • Coldなストリームとして1, 2, 3が流れる
  • flatMapを挟む
    • 1つ目のイベントでは1秒後に遅らせてイベントを文字列にして流す
    • 1つ目でなければ非同期でイベントを文字列にして流す
  • sinkで観測

何がやりたいかと言うと、flatMapで一発目のストリームの終了時間を遅くして次のストリームまでの実行が遅れるかどうかが知りたい。

先に結果

  • maxPublishersが.unlimitedならflatMapで作成したストリームは非同期に実行される
  • maxPublishersが.max(1)ならflatMapで作成したストリームは同期的に実行される

検証コード

デフォルトの.unlimitedでの例

引数maxPublishersを省略すると、.flatMap(maxPublishers: .unlimited)になります。

import Foundation
import Combine
import PlaygroundSupport

let page = PlaygroundPage.current
page.needsIndefiniteExecution = true

let cancelable = (1...3).publisher
    .flatMap { value -> Future<String, Never> in
        print("?flatMap: \(value)")
        if value == 1 {
            return Future<String, Never> { promise in
                let v = value
                // 1発目だけ遅らせたい
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                    print("?after: \(v)")
                    promise(.success("\(v)"))
                }
            }
        } else {
            return Future<String, Never> { promise in
                let v = value
                DispatchQueue.main.async {
                    print("?after : \(v)")
                    promise(.success("\(v)"))
                }
            }
        }
    }
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("?sink finished:", completion)
        case .failure(let error):
            print("?sink failure:", error)
        }
    }) { value in
        print("?sink received: \(String(describing: value))")
    }

DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
    print("終わったやろ")
    page.finishExecution()
}

出力は次の通り。解説を//で加えます

?flatMap: 1
?flatMap: 2        // 1の購読を待たず2のflatMap開始
?flatMap: 3        // 2の購読も待たず
?after : 2
?sink received: 2
?after : 3
?sink received: 3
?after: 1
?sink received: 1   // 1のflatMapのストリームが購読された
?sink finished: finished
終わったやろ

flatMapは呼び出されそのままflatMap内でストリームが実行されました。

  • デフォルトのflatMapはすでに実行されたストリームの購読の終了を待たない

.max(1)での例

.flatMap(maxPublishers: .max(1))だけ変更します。

先程のコードから次のように変更してください

.flatMap { value -> Future<String, Error> in

.flatMap(maxPublishers: .max(1)) { value -> Future<String, Error> in

出力を見ると、1が購読されるまで遅延させられていたのが分かるはずです。//で解説を加えています

?flatMap: 1
?after: 1
?sink received: 1 // 1の購読が終わり
?flatMap: 2       // flatMapの2つ目が遅延させられていた
?after: 2
?sink received: 2
?flatMap: 3
?after: 3
?sink received: 3
?sink finished: finished
終わったやろ

これでflatMapはストリームのイベント発生とそのsinkが終わるまで次のは実行されていないのがわかります

検証2: flatMapでエラーを流す場合上流は止まってる?

次にflatMapでエラーを流してみるとどうなるか、というのをやってみたいと思います。
なぜかというと、flatMapでエラーを流してその下流は止められるんですが、上流は止まらないのです。

デフォルトの.unlimitedでの例

import Foundation
import Combine
import PlaygroundSupport

let page = PlaygroundPage.current
page.needsIndefiniteExecution = true

let cancelable = (1...3).publisher
    .handleEvents(receiveOutput: {
        print("❗handle \($0)")
    }, receiveCompletion: { _ in
        print("❗handle completion")
    }, receiveCancel: {
        print("❗handle cancel")
    })
    .setFailureType(to: Error.self)
    .flatMap { value -> Future<String, Error> in
        print("?flatMap: \(value)")
        if value == 1 {
            return Future<String, Error> { promise in
                let v = value
                // 1発目からエラー
                print("error : \(v)")
                promise(.failure(NSError(domain: "test", code: 1)))
            }
        } else {
            return Future<String, Error> { promise in
                let v = value
                print("success : \(v)")
                promise(.success("\(v)"))
            }
        }
    }
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("?sink finished:", completion)
        case .failure(let error):
            print("?sink failure:", error)
        }
    }) { value in
        print("?sink received: \(String(describing: value))")
    }

DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
    print("終わったやろ")
    page.finishExecution()
}

出力は次の通り、handleが出力されているので止まってないと思えます。

❗handle 1
?flatMap: 1
error : 1
?sink failure: Error Domain=test Code=1 "(null)"
❗handle 2            // flatMapより上流は動作している
❗handle 3            // これも
❗handle completion   // エラーが起きてるのにそれは関係なく
終わったやろ

これはすごく不思議な気がします。エラーになってるのに上流は止まっていない。

.max(1)での例

.max(1)にするも念の為やってみましょう

先程のコードから次のように変更してください

.flatMap { value -> Future<String, Error> in

.flatMap(maxPublishers: .max(1)) { value -> Future<String, Error> in

結果は次の通り、1発目のエラーで終了していますが、handleも出力していないため上流も止めています。

❗handle 1
?flatMap: 1
error : 1
?sink failure: Error Domain=test Code=1 "(null)"
終わったやろ

上流を止めていると言うか、coldなストリームを下流のflatMapで購読していないと思えるのでこれが普通な気がします。

つまり、flatMapはエラーにしてるのにストリームをdisposeしてない。RxSwiftだとdisposeされて数珠つなぎになった購読が止まると思うんですが、Combineはそうなっておらず、そもそもdisposeという概念がないため、ストリームの上流を止めるにも使っていけということですかね?

そしてこれがバックプレッシャーを使うということでしょうかね?

.flatMap(maxPublishers: .max(1))が向いてない処理は

インクリメンタルサーチには向いてない

インクリメンタルサーチは次々に入力が来て、古い検索は気にせず新しい検索を投げたい処理です。
そのため、.flatMap(maxPublishers: .max(1))は処理を待ってしまうので向いてなさそうです。

まとめ

言いたいことが2つになっちゃってますが、次のようなことを確認しました。

  • flatMapのmaxPublishers: Subscribers.Demand引数
    • maxの数より多くflatMapで新しいストリームはつくられない
  • Combineのストリームはエラーになっても上流は止まらない
    • エラーになった場合にmaxPublishersに指定数超えて新しくリクエストしないのでflatMapの上流も処理を止められる

感想

感想としては.max(1)でWeb API利用時やファイルアクセス時には積極的にバックプレッシャー使えば良さそうなんじゃない?と思います。デフォルトでバックプレッシャーを使わず無制限なのはRxJavaでもデフォルトだから仕様としてそうなってるんだとは思いますが...。

かなり自信がない考察

おそらく、.max(1)は実行が完了するとあらためて1つをリクエストすることで、RxJavaでいうとFlowableに対してデータを受ける側のSubscriptionrequest(1)にしているのと同じ?

RxJavaの資料から次のrequestがそれにあたるのかな。

そうなると.max(1)は要求したデータ数1を受け取ったらさらに1を要求するということですかね?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【iOS】UICircularProgressRingを試す

UICircularProgressRingとは?

↓ こちらを見れば一目瞭然ですが、よく見かける丸型のProgressを簡単に実現できるライブラリです。

:computer:環境構築


UICircularProgressRing自体はCocoaPodsCarthageでも導入できます。
今回はCarthageで導入します。
まずは以下の内容でCartfileを作成。

github "luispadron/UICircularProgressRing"

次に以下コマンドで手元にダウンロード&ビルドします。

$ carthage update --platform iOS

Xcodeに戻ってTargetsのGeneralの「Framework, Libraries, and Embedded Content」に
./Carthage/Build/iOS/UICircularProgressRing.frameworkを追加。

次に「Build Phases」で新規に「New Run Script Phases」を追加し

/usr/local/bin/carthage copy-frameworks

上記を設定し、Input Files$(SRCROOT)/Carthage/Build/iOS/UICircularProgressRing.frameworkを設定します。

ここまででライブラリの導入は完了になります。

:pencil: 実装


簡単なサンプル

README に乗っていた単純なサンプルを少し修正して試してみます。

import UIKit
import UICircularProgressRing

class ViewController: UIViewController {

    private let progressRing = UICircularProgressRing()
    override func viewDidLoad() {
        super.viewDidLoad()

        progressRing.maxValue = 50
        progressRing.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
        progressRing.center = self.view.center
        self.view.addSubview(progressRing)
    }
    // Viewのレイアウトが決定した後じゃないとアニメーションされない
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        progressRing.startProgress(to: 50, duration: 2.0) {
          print("Done animating!")
        }
    }
}

↓シュミレータで実施した結果


UICircularRingStyle

UICircularProgressRing で設定できるスタイルとして以下があります。

  • inside (デフォルト ↑と同じ)
  • ontop

  • dashed ( .dashed(pattern: [7.0, 7.0]) )

  • dotted

  • bordered ( .bordered(width: 2.0, color: UIColor.red) )


中のテキストを非表示にする

以下のプロパティ shouldShowValueTextfalse に設定すれば非表示になります。

progressRing.shouldShowValueText = false


リングカラーを設定する

        progressRing.outerRingColor = UIColor.red
        progressRing.innerRingColor = UIColor.blue


開始アングルを設定する

startAngle で設定可能。デフォルトは 0 で設定されています。

  • startAngle=90

  • startAngle=180

  • startAngle=270


UICircularTimerRing

Timerを設定できる様にしたクラスです。

import UIKit
import UICircularProgressRing

class ViewController: UIViewController {

    private let timerRing = UICircularTimerRing()
    override func viewDidLoad() {
        super.viewDidLoad()
        timerRing.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
        timerRing.center = self.view.center
        self.view.addSubview(timerRing)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        timerRing.startTimer(to: 10) { state in
            print("state: \(state)")
        }
    }
}

↑のサンプルでは10秒後にリングが閉じる様に設定しています。

:link: 参考になったURL


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む