Enable streaming TTS to Sonos

This commit is contained in:
2020-05-14 18:44:57 +02:00
parent 886d215f6f
commit ddadd907c9
6 changed files with 150 additions and 58 deletions

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="io.bartek"> package="io.bartek">
<permission <permission
@@ -8,6 +9,7 @@
android:label="@string/permission_http_server_label" android:label="@string/permission_http_server_label"
android:protectionLevel="normal" /> android:protectionLevel="normal" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -20,7 +22,8 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true"
tools:targetApi="m">
<activity <activity
android:name="io.bartek.ttsserver.help.HelpActivity" android:name="io.bartek.ttsserver.help.HelpActivity"
android:parentActivityName=".ttsserver.MainActivity" /> android:parentActivityName=".ttsserver.MainActivity" />

View File

@@ -0,0 +1,22 @@
package io.bartek.ttsserver.network
import android.content.Context
import android.content.Context.WIFI_SERVICE
import android.net.wifi.WifiManager
import java.net.InetAddress
object NetworkUtil {
fun getIpAddress(context: Context): String {
return (context.getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager).let {
inetAddress(it.dhcpInfo.ipAddress).toString().substring(1)
}
}
private fun inetAddress(hostAddress: Int) = byteArrayOf(
(0xff and hostAddress).toByte(),
(0xff and (hostAddress shr 8)).toByte(),
(0xff and (hostAddress shr 16)).toByte(),
(0xff and (hostAddress shr 24)).toByte()
).let { InetAddress.getByAddress(it) }
}

View File

@@ -0,0 +1,17 @@
package io.bartek.ttsserver.sonos
import com.vmichalak.sonoscontroller.SonosDiscovery
import java.io.File
class SonosController(private val host: String, private val port: Int) {
fun clip(wave: File, zone: String, volume: Int) {
SonosDiscovery.discover().firstOrNull { it.zoneGroupState.name == zone }?.let {
val filename = wave.name
val url = "http://$host:$port/sonos/$filename"
val currentVolume = it.volume
it.volume = volume
it.clip(url, "")
it.volume = currentVolume
}
}
}

View File

@@ -0,0 +1,30 @@
package io.bartek.ttsserver.web
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.ResponseException
import org.json.JSONObject
import java.util.*
data class SonosTTSRequestData(val text: String, val language: Locale, val zone: String, val volume: Int) {
companion object {
fun fromJSON(json: String): SonosTTSRequestData {
val root = JSONObject(json)
val language = root.optString("language")
.takeIf { it.isNotBlank() }
?.let { Locale(it) }
?: Locale.US
val text = root.optString("text") ?: throw ResponseException(
NanoHTTPD.Response.Status.BAD_REQUEST,
""
)
val zone = root.optString("zone") ?: throw ResponseException(
NanoHTTPD.Response.Status.BAD_REQUEST,
""
)
val volume = root.optInt("volume", 50)
return SonosTTSRequestData(text, language, zone, volume)
}
}
}

View File

@@ -0,0 +1,25 @@
package io.bartek.ttsserver.web
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.ResponseException
import org.json.JSONObject
import java.util.*
data class TTSRequestData(val text: String, val language: Locale) {
companion object {
fun fromJSON(json: String): TTSRequestData {
val root = JSONObject(json)
val language = root.optString("language")
.takeIf { it.isNotBlank() }
?.let { Locale(it) }
?: Locale.US
val text = root.optString("text") ?: throw ResponseException(
NanoHTTPD.Response.Status.BAD_REQUEST,
""
)
return TTSRequestData(text, language)
}
}
}

View File

@@ -8,17 +8,15 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.Response.Status.* import fi.iki.elonen.NanoHTTPD.Response.Status.*
import io.bartek.ttsserver.network.NetworkUtil
import io.bartek.ttsserver.preference.PreferenceKey import io.bartek.ttsserver.preference.PreferenceKey
import io.bartek.ttsserver.service.ForegroundService import io.bartek.ttsserver.service.ForegroundService
import io.bartek.ttsserver.service.ServiceState import io.bartek.ttsserver.service.ServiceState
import io.bartek.ttsserver.sonos.SonosController
import io.bartek.ttsserver.tts.TTS import io.bartek.ttsserver.tts.TTS
import org.json.JSONObject
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.util.*
private data class TTSRequestData(val text: String, val language: Locale)
class WebServer(port: Int, private val context: Context) : NanoHTTPD(port), class WebServer(port: Int, private val context: Context) : NanoHTTPD(port),
@@ -26,6 +24,7 @@ class WebServer(port: Int, private val context: Context) : NanoHTTPD(port),
private val preferences = PreferenceManager.getDefaultSharedPreferences(context) private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
private val tts = TTS(context, this) private val tts = TTS(context, this)
private val endpoints = Endpoints() private val endpoints = Endpoints()
private val sonos = SonosController(NetworkUtil.getIpAddress(context), port)
override fun serve(session: IHTTPSession?): Response { override fun serve(session: IHTTPSession?): Response {
try { try {
@@ -47,9 +46,10 @@ class WebServer(port: Int, private val context: Context) : NanoHTTPD(port),
} }
} }
private fun sonos(session: IHTTPSession): Response { private fun say(session: IHTTPSession): Response {
// SonosDiscovery.discover().firstOrNull { it.zoneGroupState.name == "Salon" } if (!preferences.getBoolean(PreferenceKey.ENABLE_SAY_ENDPOINT, true)) {
// ?.play() throw ResponseException(NOT_FOUND, "")
}
if (session.method != Method.POST) { if (session.method != Method.POST) {
throw ResponseException(METHOD_NOT_ALLOWED, "") throw ResponseException(METHOD_NOT_ALLOWED, "")
@@ -59,9 +59,52 @@ class WebServer(port: Int, private val context: Context) : NanoHTTPD(port),
throw ResponseException(BAD_REQUEST, "") throw ResponseException(BAD_REQUEST, "")
} }
val (text, language) = getRequestData(session) val (text, language) = extractBody(session) { TTSRequestData.fromJSON(it) }
tts.performTTS(text, language)
return newFixedLengthResponse(OK, MIME_PLAINTEXT, "")
}
private fun <T> extractBody(session: IHTTPSession, provider: (String) -> T): T {
return mutableMapOf<String, String>().let {
session.parseBody(it)
provider(it["postData"] ?: "{}")
}
}
private fun wave(session: IHTTPSession): Response {
if (!preferences.getBoolean(PreferenceKey.ENABLE_WAVE_ENDPOINT, true)) {
throw ResponseException(NOT_FOUND, "")
}
if (session.method != Method.POST) {
throw ResponseException(METHOD_NOT_ALLOWED, "")
}
if (session.headers[CONTENT_TYPE]?.let { it != MIME_JSON } != false) {
throw ResponseException(BAD_REQUEST, "")
}
val (text, language) = extractBody(session) { TTSRequestData.fromJSON(it) }
val (stream, size) = tts.fetchTTSStream(text, language)
return newFixedLengthResponse(OK, MIME_WAVE, stream, size)
}
private fun sonos(session: IHTTPSession): Response {
if (session.method != Method.POST) {
throw ResponseException(METHOD_NOT_ALLOWED, "")
}
if (session.headers[CONTENT_TYPE]?.let { it != MIME_JSON } != false) {
throw ResponseException(BAD_REQUEST, "")
}
val (text, language, zone, volume) = extractBody(session) { SonosTTSRequestData.fromJSON(it) }
val file = tts.createTTSFile(text, language) val file = tts.createTTSFile(text, language)
return newFixedLengthResponse(file.toString()) sonos.clip(file, zone, volume)
return newFixedLengthResponse("")
} }
private fun sonosCache(session: IHTTPSession): Response { private fun sonosCache(session: IHTTPSession): Response {
@@ -81,54 +124,6 @@ class WebServer(port: Int, private val context: Context) : NanoHTTPD(port),
return newFixedLengthResponse(OK, MIME_WAVE, stream, size) return newFixedLengthResponse(OK, MIME_WAVE, stream, size)
} }
private fun wave(session: IHTTPSession): Response {
if (!preferences.getBoolean(PreferenceKey.ENABLE_WAVE_ENDPOINT, true)) {
throw ResponseException(NOT_FOUND, "")
}
if (session.method != Method.POST) {
throw ResponseException(METHOD_NOT_ALLOWED, "")
}
if (session.headers[CONTENT_TYPE]?.let { it != MIME_JSON } != false) {
throw ResponseException(BAD_REQUEST, "")
}
val (text, language) = getRequestData(session)
val (stream, size) = tts.fetchTTSStream(text, language)
return newFixedLengthResponse(OK, MIME_WAVE, stream, size)
}
private fun say(session: IHTTPSession): Response {
if (!preferences.getBoolean(PreferenceKey.ENABLE_SAY_ENDPOINT, true)) {
throw ResponseException(NOT_FOUND, "")
}
if (session.method != Method.POST) {
throw ResponseException(METHOD_NOT_ALLOWED, "")
}
if (session.headers[CONTENT_TYPE]?.let { it != MIME_JSON } != false) {
throw ResponseException(BAD_REQUEST, "")
}
val (text, language) = getRequestData(session)
tts.performTTS(text, language)
return newFixedLengthResponse(OK, MIME_PLAINTEXT, "")
}
private fun getRequestData(session: IHTTPSession): TTSRequestData {
val map = mutableMapOf<String, String>()
session.parseBody(map)
val json = JSONObject(map["postData"] ?: "{}")
val language = json.optString("language")
.takeIf { it.isNotBlank() }
?.let { Locale(it) }
?: Locale.US
val text = json.optString("text") ?: throw ResponseException(BAD_REQUEST, "")
return TTSRequestData(text, language)
}
override fun onInit(status: Int) = start() override fun onInit(status: Int) = start()
override fun start() { override fun start() {