Enable streaming TTS to Sonos
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
22
app/src/main/java/io/bartek/ttsserver/network/NetworkUtil.kt
Normal file
22
app/src/main/java/io/bartek/ttsserver/network/NetworkUtil.kt
Normal 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) }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/src/main/java/io/bartek/ttsserver/web/TTSRequestData.kt
Normal file
25
app/src/main/java/io/bartek/ttsserver/web/TTSRequestData.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user