トップ 追記

Cocoa練習帳

iOS/iPhone/iPad/watchOS/tvOS/MacOSX/Android プログラミング, Objective-C, Cocoa, Swiftなど

2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|12|
2020|01|02|03|04|05|06|07|08|09|10|11|12|
2021|01|02|03|04|05|06|07|08|09|10|11|12|
2022|01|02|03|04|05|06|07|08|09|10|11|12|
2023|01|02|03|04|05|06|07|08|09|10|11|12|
2024|01|02|03|04|05|06|07|08|

2024-08-09 [SwiftUI] UI部品

ヒレガス本『Cocoa Programming for OS X (5th Edition)』のサンプルRGBWellをSwiftUIで実装してみた。

RGBWell

NSColorWellの操作で得られた色のRGBをNSSliderの値に反映し、NSSliderの操作で変更したRGBをNSColorWellに変異するというサンプルだ。

AppKitとUIKitでUI部品のクラスに差異があるが、SwiftUIではColorPickerやSlider、Colorと共通のクラスとなっている。ただし、Colorの内部の色情報はmacOSではNSColor、iOSではUIColorと差異があるのでRGBを値として取り出す場合は、macOSとiOSで異なる型になるようにする必要があった。

#if os(iOS)
typealias MyColor = UIColor
#elseif os(macOS)
typealias MyColor = NSColor
#else
#error("your os is not supported")
#endif
 
extension MyColor {
    var rgba: (red: Double, green: Double, blue: Double, alpha: Double) {
        var red: CGFloat = 0
        var green: CGFloat = 0
        var blue: CGFloat = 0
        var alpha: CGFloat = 0
        getRed(&red, green: &green, blue: &blue, alpha: &alpha)
        return (Double(red), Double(green), Double(blue), Double(alpha))
    }
}
 
extension Color {
    var rgbValues:(red: Double, green: Double, blue: Double){
        let rgba = MyColor(self).rgba
        return (rgba.red, rgba.green, rgba.blue)
    }
}

ColorPickerで得られるのはColor構造体だ。ColorPickerによって変更が発生したイベントを受け取るために@Observableのクラスを用意した。

@Observable
final class MyData {
    var color = Color(.sRGB, red: 0.98, green: 0.9, blue: 0.2)
    var red = 0.98
    var green = 0.9
    var blue = 0.2
}

ただし、Color構造体は更新不可なので、RGBスライダーで値が変更されても、ColorPickerが利用しているColorの値は更新できないので、スライダーによる変更は諦めた。

struct ContentView: View {
    @State private var myData = MyData()
 
    var body: some View {
        HStack {
            ColorPicker("", selection: $myData.color)
            VStack {
                HStack {
                    Text("R")
                    Slider(
                        value: $myData.red,
                        in: 0.0...1.0
                    ).disabled(true)
                }
                HStack {
                    Text("G")
                    Slider(
                        value: $myData.green,
                        in: 0.0...1.0
                    ).disabled(true)
                }
                HStack {
                    Text("B")
                    Slider(
                        value: $myData.blue,
                        in: 0.0...1.0
                    ).disabled(true)
                }
            }
            .padding()
        }.padding().onAppear {
            trackingColor(myData)
        }
    }
    
    nonisolated private func trackingColor(_ myData: MyData) {
        withObservationTracking {
            _ = myData.color
        } onChange: {
            let rgb = myData.color.rgbValues
            myData.red = rgb.red
            myData.green = rgb.green
            myData.blue = rgb.blue
            trackingColor(myData)
        }
    }
}

ヒレガス本では、

let newColor = NSColor(calibratedRed: CGFloat(r),
                               green: CGFloat(g),
                               blue: CGFloat(b),
                               alpha: CGFloat(a))
colorWell.color = newColor 

とNSColorWellのcolorを更新することで値が変更していたが、これは少々トリッキーなコードだがサンプルだったので、と考えて、SwiftUIで同じことを行うことは諦めた。

_ 【ソースコード】

GitHubからどうぞ。
https://github.com/murakami/workbook/tree/master/multiplatform/RGBWell - GitHub

2024-07-30 [SwiftUI] ヒレガス本に挑戦する

SwiftUIの学習のため、ヒレガス本『Cocoa Programming for OS X (5th Edition)』のサンプルをSwiftUIで実装していく。

第一弾はボタンと押すとテキスト領域にパスワードを生成して表示するアプリだ。

RandomPassword

書籍ではnibまたはStoryboardと独自のWindowControllerまたはViewControllerを関連づけて、ボタン押下イベントを受け取りテキスト領域にパスワードを表示されていたが、これをSwiftUIで組んだ。

"GeneratePassword.swift"。基本的に書籍のサンプルのまま。

import Foundation
 
private let characters = Array("0123456789abcdefghijklmnopqrstuvwxyz" +
                               "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
 
func generateRandomString(length: Int) -> String {
    var string = ""
    for index in 0.. Character {
    let index = Int(arc4random_uniform(UInt32(characters.count)))
    let character = characters[index]
    return character
}

”ContentView”。ここに画面の宣言とイベントに対する操作を実装する。

import SwiftUI
 
struct ContentView: View {
    @State private var text: String = ""
    var body: some View {
        VStack {
            Text(text)
            
            Button("Generate Password") {
                let length = 8
                let password = generateRandomString(length: length)
                text = password
            }
        }
        .padding()
    }
}
 
#Preview {
    ContentView()
}

生成したパスワードを表示するテキスト領域とパスワード生成ボタンを縦に並べている。

テキスト領域のクラスは Cocoa (AppKit) と CocoaTouch (UIKIt) で異なっているが、SwiftUIは両者のどちらかに引きずられるのではなくで、新たの機能に合わせて用意しているようだ。

_ 【ソースコード】

GitHubからどうぞ。
https://github.com/murakami/workbook/tree/master/multiplatform/RandomPassword - GitHub

2024-06-21 [macOS] MACアドレスを印字する

アプリ内課金のレシートを自分で解析する方法を調べていたら、公式ドキュメントにIO Kitを使ったMACアドレスを取り出すコードが説明されていたので、面白いので、Swiftで実装したコマンドラインのプログラムとして動かしてみた。

import IOKit
import Foundation
 
// Returns an object with a +1 retain count; the caller needs to release.
func io_service(named name: String, wantBuiltIn: Bool) -> io_service_t? {
    let default_port = kIOMainPortDefault
    var iterator = io_iterator_t()
    defer {
        if iterator != IO_OBJECT_NULL {
            IOObjectRelease(iterator)
        }
    }
 
    // CFMutableDictionaryRef IOBSDNameMatching(mach_port_t mainPort, uint32_t options, const char *bsdName);
    guard let matchingDict = IOBSDNameMatching(default_port, 0, name),
        // kern_return_t IOServiceGetMatchingServices(mach_port_t mainPort, CFDictionaryRef matching, io_iterator_t *existing);
        IOServiceGetMatchingServices(default_port,
                                     matchingDict as CFDictionary,
                                     &iterator) == KERN_SUCCESS,
        iterator != IO_OBJECT_NULL
    else {
        return nil
    }
 
    // io_object_t IOIteratorNext(io_iterator_t iterator);
    var candidate = IOIteratorNext(iterator)
    while candidate != IO_OBJECT_NULL {
        // CFTypeRef IORegistryEntryCreateCFProperty(io_registry_entry_t entry, CFStringRef key, CFAllocatorRef allocator, IOOptionBits options);
        if let cftype = IORegistryEntryCreateCFProperty(candidate,
                                                        "IOBuiltin" as CFString,
                                                        kCFAllocatorDefault,
                                                        0) {
            let isBuiltIn = cftype.takeRetainedValue() as! CFBoolean
            if wantBuiltIn == CFBooleanGetValue(isBuiltIn) {
                return candidate
            }
        }
 
        IOObjectRelease(candidate)
        candidate = IOIteratorNext(iterator)
    }
 
    return nil
}
 
func copy_mac_address() -> CFData? {
    // Prefer built-in network interfaces.
    // For example, an external Ethernet adaptor can displace
    // the built-in Wi-Fi as en0.
    guard let service = io_service(named: "en0", wantBuiltIn: true)
            ?? io_service(named: "en1", wantBuiltIn: true)
            ?? io_service(named: "en0", wantBuiltIn: false)
        else { return nil }
    defer { IOObjectRelease(service) }
 
    // CFTypeRef IORegistryEntrySearchCFProperty(io_registry_entry_t entry, const io_name_t plane, CFStringRef key, CFAllocatorRef allocator, IOOptionBits options);
    if let cftype = IORegistryEntrySearchCFProperty(
        service,
        kIOServicePlane,
        "IOMACAddress" as CFString,
        kCFAllocatorDefault,
        IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents)) {
            return (cftype as! CFData)
    }
 
    return nil
}
 
func hexaDecimalString(_ cfData: CFData) -> String {
    let data: Data = cfData as Data;
    let value = data.map {
        String(format: "%.2hhx", $0)
    }
    .joined(separator: ":")
    return value
}
 
if let cfData = copy_mac_address() {
    print("\(cfData)")
    print("\(hexaDecimalString(cfData))")
}

2024-05-02 [macOS][Maps] 公共交通データを使う

公共交通の標準データフォーマット General Transit Feed Specification (GTFS) を可視化してみます。

HomebrewでDuckDBをインストール。

$ brew install duckdb

岡山県の宇野バス(宇野自動車)のGTFSをダウンロード。

$ curl -L -o uno-GTFS.zip "https://api-public.odpt.org/api/v4/files/odpt/UnoBus/AllLines.zip?date=20231208"

ダウンロードしたファイルを解凍。

$ unzip uno-GTFS.zip -d uno-GTFS

内容を確認。

$ ls -l uno-GTFS
agency.txt                 運行会社情報
calendar.txt               運行日情報
calendar_dates.txt         運行日付情報
fare_attributes.txt        運賃情報
fare_rules.txt             運賃ルール情報
feed_info.txt              フィード情報
routes.txt                 路線情報
shapes.txt                 路線形状情報
stops.txt                  停留所情報
stop_times.txt             時刻表情報
translations.txt
trips.txt                  便情報

DuckDBを起動。

$ duckdb ViewGTFS.duckdb
v1.1.2 f680b7d08f
Enter ".help" for usage hints.
D 

Spartial Spartial Extensionを有効にする。

D INSTALL spatial;
D LOAD spatial;
D 

停留所情報と路線形状情報、便情報を取り込む。

D CREATE OR REPLACE TABLE stops AS SELECT * FROM read_csv('uno-GTFS/stops.txt');
D CREATE OR REPLACE TABLE shapes AS SELECT * FROM read_csv('uno-GTFS/shapes.txt');
D CREATE OR REPLACE TABLE trips AS SELECT * FROM read_csv('uno-GTFS/trips.txt');
D 

内容を確認。

D SELECT * FROM stops LIMIT 5;
┌─────────┬───────────┬──────────────────────┬───────────┬──────────────────┬──────────────────┬─────────┬────────────────────────────────────────┬───────────────┬───────────────┬────────────────┐
│ stop_id │ stop_code │      stop_name       │ stop_desc │     stop_lat     │     stop_lon     │ zone_id │                stop_url                │ location_type │ platform_code │ parent_station │
│ varchar │  varchar  │       varchar        │  varchar  │      double      │      double      │ varchar │                varchar                 │     int64     │     int64     │    varchar     │
├─────────┼───────────┼──────────────────────┼───────────┼──────────────────┼──────────────────┼─────────┼────────────────────────────────────────┼───────────────┼───────────────┼────────────────┤
│ 2_01    │           │ 岡山駅               │           │ 34.6649511659916 │ 133.918614140014 │ 2_01    │ https://www.unobus.co.jp/bustei/2#2_01 │             0 │            11 │                │
│ 2_04    │           │ 岡山駅               │           │        34.665088 │       133.918707 │ 2_04    │ https://www.unobus.co.jp/bustei/2#2_04 │             0 │            12 │                │
│ 2_11    │           │ 岡山駅               │           │ 34.6656276536051 │ 133.918707018287 │ 2_11    │ https://www.unobus.co.jp/bustei/2#2_11 │             0 │             1 │                │
│ 3_03    │           │ 柳川西(北側)東岡山 │           │        34.665708 │       133.923902 │ 3_03    │ https://www.unobus.co.jp/bustei/3#3_03 │             0 │               │                │
│ 4_01    │           │ 岡山駅前・ドレミの街 │           │        34.665691 │       133.921284 │ 4_01    │ https://www.unobus.co.jp/bustei/4#4_01 │             0 │             6 │                │
└─────────┴───────────┴──────────────────────┴───────────┴──────────────────┴──────────────────┴─────────┴────────────────────────────────────────┴───────────────┴───────────────┴────────────────┘
D SELECT * FROM shapes LIMIT 5;
┌──────────┬──────────────────┬──────────────────┬───────────────────┐
│ shape_id │   shape_pt_lat   │   shape_pt_lon   │ shape_pt_sequence │
│  int64   │      double      │      double      │       int64       │
├──────────┼──────────────────┼──────────────────┼───────────────────┤
│     1041 │ 34.6649511659916 │ 133.918614140014 │                 1 │
│     1041 │  34.664688828413 │ 133.918397814614 │                 2 │
│     1041 │ 34.6647661581233 │ 133.918196648608 │                 3 │
│     1041 │ 34.6656285584571 │ 133.918808190945 │                 4 │
│     1041 │        34.665691 │       133.921284 │                 5 │
└──────────┴──────────────────┴──────────────────┴───────────────────┘
D SELECT * FROM trips LIMIT 5;
┌────────────────┬────────────┬────────────────────────┬─────────────────────────┬──────────┬─────────────────┬──────────────┬──────────┐
│    route_id    │ service_id │        trip_id         │      trip_headsign      │ block_id │ trip_short_name │ direction_id │ shape_id │
│    varchar     │  varchar   │        varchar         │         varchar         │ varchar  │     varchar     │   varchar    │  int64   │
├────────────────┼────────────┼────────────────────────┼─────────────────────────┼──────────┼─────────────────┼──────────────┼──────────┤
│ ネオ瀬戸線_A   │ 平日       │ 平日_05時40分_系統1642 │ 瀬戸駅(岡山駅 経由)   │          │                 │              │     1642 │
│ 林野線_A       │ 平日       │ 平日_06時00分_系統1332 │ 表町BC(新道河本 経由) │          │                 │              │     1332 │
│ 林野線_A       │ 平日       │ 平日_06時00分_系統1182 │ 表町BC(新道河本 経由) │          │                 │              │     1182 │
│ ネオポリス線_A │ 平日       │ 平日_06時05分_系統1282 │ 表町BC(下市・中 経由) │          │                 │              │     1282 │
│ ネオポリス線_A │ 平日       │ 平日_06時10分_系統1632 │ 表町BC(下市・西 経由) │          │                 │              │     1632 │
└────────────────┴────────────┴────────────────────────┴─────────────────────────┴──────────┴─────────────────┴──────────────┴──────────┘
D 

Kepler.gl向けのデータに変換する。

D COPY (
      SELECT * FROM stops
  ) TO 'uno-GTFS_stops.csv' WITH (FORMAT CSV, HEADER);
D COPY (
      SELECT * FROM trips
      LEFT JOIN (
          SELECT
              shape_id,
              {
                  "type": 'LineString',
                  "coordinates": list([shape_pt_lon,shape_pt_lat])
              }::JSON AS "geometry"
          FROM shapes GROUP BY shape_id
      ) AS shapes ON trips.shape_id = shapes.shape_id
  ) TO 'uno-GTFS_trips.csv' WITH (FORMAT CSV, HEADER);
D 

https://kepler.gl/demo/ をWebブラウザで開く。

先ほど出力した uno-GTFS_stops.csv と uno-GTFS_trips.csvをDrag&Dropする。

画像の説明

停留所と路線形状が表示されましたが、地図が表示されていませんね。


2024-04-01 [UNIX] シグナル

シグナルはソフトウェア割り込みで、ある条件が生起したことをプロセスに通知する仕組みです。

シグナルを扱う最も簡素な関数はsignal.hで定義されているsignal関数です。

void (*signal(int sig, void (*handler)(int)))(int)

4.4BSDでは信頼できるsigaction関数を利用すべきですが、signal関数はプラットフォームに依存しないのと、単純ですので、signal関数を使っています。

以下はシグナルの例です。

SIGABRT 異常終了。abortなど。
SIGFPE 異常算術エラー。ゼロ割算やオーバーフローなど。
SIGILL 不当な関数イメージ。不当命令など。
SIGINT 対話的なアテンション。割り込みなど。
SIGSEGV 不当な記憶アクセス。メモリ領域外へのアクセスなど。
SIGTERM プログラムに対する終了要求。

簡単な例です。

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
 
static void onintr(int);
 
int main(int argc, char *argv[])
{
    if (signal(SIGINT, SIG_IGN) != SIG_IGN)
        signal(SIGINT, onintr);
 
    for (;;)
        sleep(1);
    exit(0);
}
 
static void onintr(int signo)
{
    if (signo == SIGINT)
        printf("received SIGINT\n");
    exit(1	);
}

これをビルドして実行すると無限ループするので、Ctrl+Cで中断させる。するとSIGINTを受け取って、コンソールにreceived SIGINTが印字される。


$ cc onintr.c -o onintr
$ ./onintr
^Creceived SIGINT
$ 

2024-03-05 [Android] JNI事始め

JNI (Java Native Interface) は、Java仮想マシン (JVM) で実行されるJavaコードと、C言語などのネイティブ・プログラミング言語のコードを相互運用できるようにするためのインタフェースです。

_ 概要

ざっくりと説明すると、以下のことができるようになります。

  • JavaコードからC言語の関数が呼べる。
  • C言語のコードからJavaのメソッドが呼べる。

_ NDK (Native Development Kit)

AndroidでCやC++のコードを使えるようにするツールセットです。

_ ndk-buildとCMake

現在デフォルトのビルドツールはCMakeで、以前は、ndk-buildでした。古い資料はndk-buildを想定した説明になっていますので、CMakeに読み替えることになります。

_ Javaコードからネイティブコードを呼び出す例

以下のC言語の関数が、demoライブラリモジュールに用意されているものとします。

JNIEXPORT void JNICALL Java_com_example_Demo_printString(JNIEnv *env, jobject obj, jstring javaString)
{
    const char *nativeString = (*env)->GetStringUTFChars(env, javaString, NULL);
    printf("%s\n", nativeString);
    (*env)->ReleaseStringUTFChars(env, javaString, nativeString);
}

以下のJavaコードから呼び出します。

package com.example;
public class Demo {
    static {
        System.loadLibrary("demo");
    }
    public static native printString(String s);
    public static void main(String[] args) {
        printString("hello, world");
    }
    public static void dump() {
        System.out.println("hello, world");
    }
}

C言語の関数が、JavaのクラスDemoのメソッドとして扱われています。

_ ネイティブコードからJavaコードを呼び出す例

JNIEnvへのポインタを取得する。

JavaVM *g_vm;
JNIEnv *env = NULL;
g_vm->AttachCurrentThread((void **)&env, NULL);

Javaコードを呼び出す。

jclass jcDemo = env->FindClass("com/example/Demo");
jfieldID jfDemoDump = env->GetStaticFieldID(jcDemo, "dump", "(V)V");
env->CallStaticVoidMethod(jcDemo, jfDemoDump);
env->DeleteLocalRef(jcDemo);

デタッチする。

g_vm->DetachCurrentThread();

2024-02-16 [Android] TextToSpeech

文章読み上げの機能を利用するためには、AndroidManifest.xmlでの宣言が必要です。

<manifest>
    <application>
    </application>
 
    <queries>
        <intent>
            <action android:name="android.intent.action.TTS_SERVICE" />
        </intent>
    </queries>
</manifest>

Jetpack Compose的なグローバルな変数を保持する方法はあると思いますが、TextToSpeechインスタンスはsetContentの外のメンバーとして保持しています。

class MainActivity : ComponentActivity(), TextToSpeech.OnInitListener {
    private var textToSpeech: TextToSpeech? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        this.textToSpeech = TextToSpeech(this, this)
        setContent {
            TextToSpeechTheme {
                Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                    Greeting(this.textToSpeech)
                }
            }
        }
    }

onInit()で初期化を

   override fun onInit(status: Int) {
        if (status == TextToSpeech.SUCCESS) {
            val locale = Locale.JAPAN
            if (this.textToSpeech!!.isLanguageAvailable(locale) >= TextToSpeech.LANG_AVAILABLE) {
                this.textToSpeech!!.language = Locale.JAPAN
            }
            // this.tts!!.speak("こんにちは", TextToSpeech.QUEUE_FLUSH, null, "utteranceId")
        }
    }
}

入力フィールドとボタンのコンテンツ。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Greeting(textToSpeech: TextToSpeech?, modifier: Modifier = Modifier) {
    val textValue = rememberSaveable { mutableStateOf("文字列を入力してください。") }
    Column {
        TextField(
            value = textValue.value,
            onValueChange = { textValue.value = it },
            label = { },
            modifier = Modifier.padding(16.dp)
        )
        Spacer(modifier = Modifier.width(8.dp))
        Button(
            onClick = {
                if (textToSpeech != null) {
                    textToSpeech!!.speak(
                        textValue.value,
                        TextToSpeech.QUEUE_FLUSH,
                        null,
                        "utteranceId"
                    )
                }
            }
        ) {
            Text("Say")
        }
    }
}

rememberSaveableで文章を永続的に保持し、入力フィールドの値をtextToSpeechに渡している。

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    TextToSpeechTheme {
        Greeting(null)
    }
}

以下のデバッグ出力を仕込んで、利用できるヴォイスをダンプしてみた。

this.textToSpeech!!.voices.forEach { voice -> Log.d("MainActivity", voice.toString()) }

量が多いので日本語関連のみ抜き出してみる。

Voice[Name: ja-jp-x-jab-network, locale: ja_JP, quality: 400, latency: 200, requiresNetwork: true, features: [networkTimeoutMs, networkRetriesCount]]
Voice[Name: ja-jp-x-htm-network, locale: ja_JP, quality: 400, latency: 200, requiresNetwork: true, features: [networkTimeoutMs, networkRetriesCount]]
Voice[Name: ja-jp-x-jad-network, locale: ja_JP, quality: 400, latency: 200, requiresNetwork: true, features: [networkTimeoutMs, networkRetriesCount]]
Voice[Name: ja-jp-x-jab-local, locale: ja_JP, quality: 400, latency: 200, requiresNetwork: false, features: [networkTimeoutMs, networkRetriesCount]]
Voice[Name: ja-jp-x-jad-local, locale: ja_JP, quality: 400, latency: 200, requiresNetwork: false, features: [networkTimeoutMs, networkRetriesCount]]
Voice[Name: ja-jp-x-jac-local, locale: ja_JP, quality: 400, latency: 200, requiresNetwork: false, features: [networkTimeoutMs, networkRetriesCount]]
Voice[Name: ja-jp-x-jac-network, locale: ja_JP, quality: 400, latency: 200, requiresNetwork: true, features: [networkTimeoutMs, networkRetriesCount]]
Voice[Name: ja-jp-x-htm-local, locale: ja_JP, quality: 400, latency: 200, requiresNetwork: false, features: [networkTimeoutMs, networkRetriesCount]]

Appleと異なり、声色毎という方向での充実ではなさそうですね。

_ 【ソースコード】

GitHubからどうぞ。
https://github.com/murakami/workbook/tree/master/android/TextToSpeech - GitHub

2024-01-01 [macOS][iOS] TextToSpeech

Macintoshの文章読み上げエンジンの歴史は古く、1984年のMacintosh発表のイベントでMacinTalkを使用して実演されました。このMacinTalkはPlainTalkと総称され、確か、これを利用するために用意されたライブラリがSpeech Managerだったと思います。

この流れからだと思いますが、Mac OS XとなってCocoaのフレームワークとして用意されたのがNSSpeechSynthesizerです。

今回、文章読み上げ(text-to-speech (TTS))を利用しようと調べたのですが、NSSpeechSynthesizerはDeprecatedとなっていました。その後継として用意されたのが、おそらく、AVFoundationに追加されたAVSpeechSynthesizerのようです。

NSSpeechSynthesizerがDeprecatedになったのは、macOSのみだったのをiOSに対応させる際、古いAPIをモダン化するために大幅な変更が発生するので、別のクラスになったのでしょう。

Speech Synthesis フレームワークの利用は簡単で、文章と音声の設定する。

// Create an utterance.
let utterance = AVSpeechUtterance(string: text)
 
// Configure the utterance.
utterance.rate = 0.57
utterance.pitchMultiplier = 0.8
utterance.postUtteranceDelay = 0.2
utterance.volume = 0.8
 
// Retrieve the Japanese voice.
let voice = AVSpeechSynthesisVoice(language: "ja-JP")
 
// Assign the voice to the utterance.
utterance.voice = voice

文章を読み上げる。

// Create a speech synthesizer.
synthesizer = AVSpeechSynthesizer()
 
// Tell the synthesizer to speak the utterance.
synthesizer!.speak(utterance)

日本語の場合、漢字の読みが文脈によって異なるので、振り仮名をつけたいなどの要望があると思いますが、Speech Synthesis Markup Language (SSML)を利用すれば文章に情報をつけることができます。

<speak>
    Hello
    <break time="1s"/>
    <prosody rate="200%">nice to meet you!</prosody>
</speak>

これをAVSpeechUtteranceのコンストラクタでSSMLだと指定して渡します。

let utterance = AVSpeechUtterance(ssmlRepresentation: ssml)

voiceは以下のコードで一覧が取得できます。

let voices = AVSpeechSynthesisVoice.speechVoices()
print("\(voices)")

ログから、日本語のvoiceを抜き出してみました。

[AVSpeechSynthesisVoice 0x600000934170] Language: ja-JP, Name: Kyoko, Quality: Enhanced [com.apple.voice.enhanced.ja-JP.Kyoko], 
[AVSpeechSynthesisVoice 0x600000934400] Language: ja-JP, Name: Otoya, Quality: Enhanced [com.apple.voice.enhanced.ja-JP.Otoya], 
[AVSpeechSynthesisVoice 0x60000093bca0] Language: ja-JP, Name: Kyoko, Quality: Default [com.apple.voice.compact.ja-JP.Kyoko], 
[AVSpeechSynthesisVoice 0x60000093bef0] Language: ja-JP, Name: Hattori, Quality: Default [com.apple.ttsbundle.siri_Hattori_ja-JP_compact], 
[AVSpeechSynthesisVoice 0x600000934330] Language: ja-JP, Name: Otoya, Quality: Default [com.apple.voice.compact.ja-JP.Otoya], 
[AVSpeechSynthesisVoice 0x6000009343d0] Language: ja-JP, Name: O-Ren, Quality: Default [com.apple.ttsbundle.siri_O-Ren_ja-JP_compact],

これはシステム設定のシステムの声の内容と一致します。

システムの声

_ 【ソースコード】

GitHubからどうぞ。
https://github.com/murakami/workbook/tree/master/multiplatform/TextToSpeech - GitHub

2023-12-17 [Swift][Kotlin]開発情報収集の振り返り

SwiftとKotlinに関係する情報は、可能な限り一次情報や、信頼できるコミュニティから得るのが良いと思いますが、今年、自分詩人が助けられた情報源についてまとめました。この情報が誰かの役に立てば嬉しいです!

_ プラットフォーム・ニュース

公式からニュースという形で公開されています。自分は、以下をよく利用しています。

プラットフォームの変更内容や、期限を設けられた対応の告知について情報が得られます。

_ 開発の公式サイト

AppleとGoogle Androidの開発サイトです。

_ 技術的な質問について

Apple Developer Programのメンバーシップには、技術的な質問をするためのTechnical Support Incidents(TSI)が付与されています。TSIは追加で購入できます。

ストア関連については、AppleとGoogleはチャットやメールで質問を受け付けています。営業日/営業時間でしたら、すぐに回答してくれますので、怪しい噂話に踊るぐらいなら、悩んだら、質問を投げることをお勧めします。

_ WWDCとGoogle I/O

WWDCとGoogle I/Oのサイトです。

_ Appleのエキスパートに相談

ウェビナーや1対1のコンサルテーションについて情報が掲載されています。

_ Google Play Console ポリシー センター

Play Consoleに関係する変更内容と対応期限については、ポリシー センターのサイトから情報が得られます。

動的に生成されるURLのようですが、以下は、現時点で対応内容と期限がまとめられいるサイトです。

対応内容と期限を説明するウェビナーも開催されていまして、こちらも動的に生成されるURLなので、過去のものとなりますが、最近開催されたサイトのURLです。

_ Play on Air

パートナー向けの完全招待制のウェビナーです。

_ コミュニティ

様々なコミュニティが存在しますが、自分がよくお邪魔するコミュニティのURLを紹介します。

SlackやDiscodeを利用しているコミュニティもありますので、悩んだら相談してみるのがいいと思います。ただ、善意での対応ですので、自分で調べれば分かることはご自身で解決を。また、解決した場合は、同様に悩んでいる方々に役立つよう、結果を報告されるのが、いいと思います。」


2023-11-11 [Android] TextToSpeech

UIはJetpack Compose。

TextToSpeechインスタンスは外のメンバーとして保持し、setContent内がReact的なコンテンツとなるようだ。

class MainActivity : ComponentActivity(), TextToSpeech.OnInitListener {
    private var textToSpeech: TextToSpeech? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        this.textToSpeech = TextToSpeech(this, this)
        setContent {
            TextToSpeechTheme {
                Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                    Greeting(this.textToSpeech)
                }
            }
        }
    }

onInit()で初期化を

    override fun onInit(status: Int) {
        if (status == TextToSpeech.SUCCESS) {
            val locale = Locale.JAPAN
            if (this.textToSpeech!!.isLanguageAvailable(locale) >= TextToSpeech.LANG_AVAILABLE) {
                this.textToSpeech!!.language = Locale.JAPAN
            }
            // this.tts!!.speak("こんにちは", TextToSpeech.QUEUE_FLUSH, null, "utteranceId")
        }
    }
}

入力フィールドとボタンのコンテンツ。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Greeting(textToSpeech: TextToSpeech?, modifier: Modifier = Modifier) {
    val textValue = rememberSaveable { mutableStateOf("文字列を入力してください。") }
    Column {
        TextField(
            value = textValue.value,
            onValueChange = { textValue.value = it },
            label = { },
            modifier = Modifier.padding(16.dp)
        )
        Spacer(modifier = Modifier.width(8.dp))
        Button(
            onClick = {
                if (textToSpeech != null) {
                    textToSpeech!!.speak(
                        textValue.value,
                        TextToSpeech.QUEUE_FLUSH,
                        null,
                        "utteranceId"
                    )
                }
            }
        ) {
            Text("Say")
        }
    }
}

rememberSaveableで文章を永続的に保持し、入力フィールドの値をtextToSpeechに渡している。

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    TextToSpeechTheme {
        Greeting(null)
    }
}

_ 【ソースコード】

GitHubからどうぞ。
https://github.com/murakami/workbook/tree/master/android/TextToSpeech - GitHub

トップ 追記