Linux でデスクトップ通知を喋らせるという話

PUBLISHED ON 2019-12-09 — PROGRAMMING

どうも、僕です。

最近めっきり寒くなりましたが、皆さんどうお過ごしですか? 今回はデスクトップ通知のお話をしたいと思います。 個人的にはなかなかに便利だったので久々にブログに書いておこうと思います。

デスクトップ通知ポップアップうざい問題

本来、デスクトップ通知はバックグラウンド処理の完了を教えてくれたりとても便利なものです。 ですが、通知ポップアップウィンドウの場所によっては逆に邪魔になりストレスになってしまうことがあります。 ストレスになってしまう点を幾つかあげてみます。

  • 無駄にフォーカスを奪われる
  • 画面上部に表示されるのでブラウザのタブなど画面上部を使うアプリケーション操作の妨げになる
  • 表示される小さめのテキストを読まなければいけない(視点をずらさないといけない)
  • マルチディスプレイの場合、片方にしか表示されず視点移動をよぎなくされる

UIでの表示はわかりやすい点、ディスプレイを見続けて常時視覚を酷使してるプログラマにとっては負担になってしまうかも知れません。 そのため今回はその他に空いてる五感である聴覚を使って視覚のストレスを下げてみようと思います。

開発環境

まずデスクトップ通知の仕組みはOSごとによって異なります。 私はLinuxを常用してるので今回の開発環境はLinux、言語はGoを使います。 正直、Linuxが一番簡単に実現できます。

Cloud Text-to-Speech を使う

まずはテキストを読み上げる処理です。 テキストを読み上げることができれば半分以上は完成したようなものです。 テキスト読み上げには Google Cloud Text-to-Speech を使います。 言語設定次第では日本語をそのまま渡せるのでとても簡単に使うことができます。 また、 Google Cloud Text-to-Speech には月額無料枠があり、メールなどの長文読み上げを頻繁に行わければ十分に無料枠で収まると思います。 ただ長文は長々と喋りすぎてしまうので場合によってはruneに変換して長い文字数になりすぎないようにしても良いでしょう。 以下コード例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package tts

import (
	"anything-tts/log"
	"context"
	"io/ioutil"
	"os"
	"os/exec"
	"sync"

	texttospeech "cloud.google.com/go/texttospeech/apiv1"
	"github.com/pkg/errors"
	texttospeechpb "google.golang.org/genproto/googleapis/cloud/texttospeech/v1"
)

var mutex sync.Mutex

func Speech(ctx context.Context, text string) error {
	if text == "" {
		return nil
	}
	mutex.Lock()
	defer mutex.Unlock()

	client, err := texttospeech.NewClient(ctx)
	if err != nil {
		return errors.Wrap(err, "failed create client")
	}

	req := texttospeechpb.SynthesizeSpeechRequest{
		Input: &texttospeechpb.SynthesisInput{
			InputSource: &texttospeechpb.SynthesisInput_Text{
				Text: text,
			},
		},

		Voice: &texttospeechpb.VoiceSelectionParams{
			LanguageCode: "ja-JP",
			SsmlGender:   texttospeechpb.SsmlVoiceGender_FEMALE,
		},

		AudioConfig: &texttospeechpb.AudioConfig{
			AudioEncoding:    texttospeechpb.AudioEncoding_MP3,
			SpeakingRate:     1.5,
			Pitch:            1.5,
			EffectsProfileId: []string{"headphone-class-device"},
		},
	}

	log.Debug("say", log.Fields{
		"Text": text,
	})

	resp, err := client.SynthesizeSpeech(ctx, &req)
	if err != nil {
		return errors.Wrap(err, "failed call tts api")
	}

	out, err := ioutil.TempFile("", "tts")
	if err != nil {
		return errors.Wrap(err, "failed create tempfile")
	}

	defer func() {
		out.Close()
		os.Remove(out.Name())
	}()

	if err := ioutil.WriteFile(out.Name(), resp.AudioContent, 0644); err != nil {
		return errors.Wrap(err, "failed write contents")
	}

	if err := exec.Command("mpg123", out.Name()).Run(); err != nil {
		return errors.Wrap(err, "failed play")
	}

	return nil
}

音声はmp3で受け取ります。再生にはmpeg123を使います。 環境によってはmpeg321かも知れません。 またraw形式(wave)で受け取りaplayで再生する方法でもいいと思います。 性別、ピッチ、スピードはお好みに合わせてカスタマイズすれば良いと思います。 (ピッチ、スピードは適度にあげるほうが良いです。)

Pushbulletの通知を取得

読み上げ部分の次に通知の読み上げを実装します。 デスクトップ通知の前にまず Pushbullet の通知を読み上げてみます。 Pushbullet にはAndroidなど他端末の通知をPCへミラーリングする機能があり、PCでの作業中もモバイルの通知を把握することができます。 通知を一箇所に集めることができるので、通知の見逃しが減るという大きなメリットがあります。 まずはこの Pushbullet の通知を読み上げてみます。 Pushbullet のAPIを使用するためOSは問いません。 事前に Pushbullet にサインインしてAPIドキュメントに目を通し、個人のトークンを発行しておきます。 以下コード例です。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
package pb

import (
	"anything-tts/log"
	"anything-tts/service"
	"anything-tts/tts"
	"context"
	"encoding/json"
	"fmt"

	"github.com/gorilla/websocket"
	"github.com/pkg/errors"
	"github.com/spf13/viper"
)

type PbService struct {
	AccessToken string
}

type pushMessage struct {
	Type string          `json:"type"`
	Push json.RawMessage `json:"push"`
}

type mirrorMessage struct {
	Type            string `json:"type"`
	Title           string `json:"title"`
	Body            string `json:"body"`
	ApplicationName string `json:"application_name"`
	PackageName     string `json:"package_name"`
}

func (s *PbService) Start() {
	url := fmt.Sprintf("wss://stream.pushbullet.com/websocket/%s", s.AccessToken)

	ctx := context.Background()
	c, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil)
	if err != nil {
		log.Error(errors.Wrap(err, "failed connect pushbullet"), nil)
		return
	}
	defer c.Close()

	for {
		_, msg, err := c.ReadMessage()
		if err != nil {
			log.Error(errors.Wrap(err, "failed read."), nil)
			return
		}

		var pmsg pushMessage
		if err := json.Unmarshal(msg, &pmsg); err != nil {
			log.Error(errors.Wrap(err, "failed json unmarshal."), nil)
			return
		}

		switch pmsg.Type {
		case "push":

			var mm mirrorMessage
			if err := json.Unmarshal(pmsg.Push, &mm); err != nil {
				log.Error(errors.Wrap(err, "failed json unmarshal."), nil)
				return
			}
			log.Debug("recv push", log.Fields{
				"app":   mm.ApplicationName,
				"type":  mm.Type,
				"title": mm.Title,
				"body":  mm.Body,
			})

			switch mm.Type {
			case "mirror":
				if err := speechMirror(ctx, mm); err != nil {
					log.Error(err, nil)
					return
				}

			}
		}
	}

}

func speechMirror(ctx context.Context, mm mirrorMessage) error {
	if err := tts.Speech(ctx, fmt.Sprintf("%sからの通知", mm.ApplicationName)); err != nil {
		return errors.Wrap(err, "failed speech.")
	}
	if err := tts.Speech(ctx, mm.Title); err != nil {
		return errors.Wrap(err, "failed speech.")
	}
	if err := tts.Speech(ctx, mm.Body); err != nil {
		return errors.Wrap(err, "failed speech.")
	}
	return nil
}

func NewService() service.Service {

	token := viper.GetString("pushbullet.access_token")
	log.Debug("pushbullet service", log.Fields{
		"token": token,
	})

	return &PbService{
		AccessToken: token,
	}

}

Pushbullet のウリは応答性です。あまり遅れずに通知メッセージをミラーリングしてくれます。 そのためメッセージを受け取る側は常時接続してメッセージの受信を待つというスタイルになります。 Pushbullet は Websocket を使った API を公開しているので Websocket Client で接続して処理を行います。 その他にもいろいろメッセージタイプがあるようなので試してみると良いかも知れません。

D-Busのデータを覗き見る

次はOSに得化したデスクトップ通知の読み上げです。 多くのLinuxデスクトップ環境では通知処理を行うための通知サーバーが実装されています。 そしてその通知サーバーの多くは D-Bus (Desktop Bus)に接続されています。 D-Bus は元々KDE由来のものでアプリケーション間でメッセージをやり取りするための仕組みです。 デスクトップ通知はアプリケーションが D-Bus に通知メッセージを送信し、D-Bus に接続している通知サーバーがそのメッセージを受信、メッセージを表示されます。 つまり、 D-Bus に流れている通知タイプのメッセージを覗き見ることができればデスクトップ通知を喋らせることができそうです。 幸いにもgoには D-Bus プロトコルを実装したライブラリ、godbus があります。 これを使って D-Bus のメッセージをモニタリングし、喋らせるコード例が以下です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package dbus

import (
	"anything-tts/log"
	"anything-tts/service"
	"anything-tts/tts"
	"context"
	"encoding/json"

	"github.com/godbus/dbus/v5"
	"github.com/pkg/errors"
)

type notification struct {
	Type int           `json:"Type"`
	Body []interface{} `json:"Body"`
}

type DbusService struct {
}

func (s *DbusService) Start() {
	ctx := context.Background()

	conn, err := dbus.SessionBus()
	if err != nil {
		log.Error(errors.Wrap(err, "failed connect d-bus"), nil)
		return
	}
	var rules = []string{
		"type='method_call',member='Notify',path='/org/freedesktop/Notifications',interface='org.freedesktop.Notifications'",
	}
	var flag uint = 0

	call := conn.BusObject().Call("org.freedesktop.DBus.Monitoring.BecomeMonitor", 0, rules, flag)
	if call.Err != nil {
		log.Error(errors.Wrap(call.Err, "failed to become monitor"), nil)
		return
	}

	c := make(chan *dbus.Message, 10)
	conn.Eavesdrop(c)
	log.Debug("Monitoring notifications", nil)
	for v := range c {
		data, err := json.Marshal(v)
		if err != nil {
			log.Error(errors.Wrap(err, "failed marshall"), nil)
			return
		}

		var n notification
		if err := json.Unmarshal(data, &n); err != nil {
			log.Error(errors.Wrap(err, "failed unmarshall"), nil)
			return
		}
		if n.Type == 1 {
			app := n.Body[0].(string)
			title := n.Body[3].(string)
			message := n.Body[4].(string)

			log.Debug("dbus", log.Fields{
				"app":     app,
				"title":   title,
				"message": message,
			})
			if err := speech(ctx, app, title, message); err != nil {
				log.Error(err, nil)
				return
			}

		}
	}
}

func speech(ctx context.Context, app, title, msg string) error {
	if err := tts.Speech(ctx, title); err != nil {
		return errors.Wrap(err, "failed speech.")
	}
	if err := tts.Speech(ctx, msg); err != nil {
		return errors.Wrap(err, "failed speech.")
	}
	return nil
}

func NewService() service.Service {

	return &DbusService{}
}

D-Bus の仕様などについては今回は説明しません。 モニタリングは godbus のexampleにあるようにBecomeMonitorを使います。 (この方法はこの先も使えるかは不明) APIはgoらしくchan経由でメッセージを受け取るような形になっていて、シンプルです。

これでデスクトップ通知を喋らせることができるようになります。 またGnomeなどで通知設定を切ってもポップアップがでなくなるだけで D-Bus にはメッセージが送信されており 喋らせることは可能です。

最後に

プログラマは割とヘッドフォンなどをつけながら仕事をしている人も多いと思うのでなかなか便利だと思います。 逆に安物のヘッドフォンを使っている人はつけっぱなしになるので耳の負担が増えてしまうかも知れません。 ただプログラムでログ出力すればシンプルに通知内容に表示することもできますし、個人でいくらでもカスタマイズ可能だと思います。 Echo でAPIを追加してテキストエディタで選択した部分を喋らせてコードを読むといったことなどいろいろ応用できそうです。

今回はデスクトップ通知を喋らせてみるというお話でした。