Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,15 @@ jobs:
strategy:
fail-fast: false
matrix: &matrix-build
abi: ["cp313"]
abi: ["cp313", "cp314"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
exclude:
- abi: "cp314"
arch: "armv7"
- abi: "cp314"
arch: "armhf"
- abi: "cp314"
arch: "i386"
steps:
- *checkout

Expand Down Expand Up @@ -163,7 +170,7 @@ jobs:

# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: &home-assistant-wheels home-assistant/wheels@2025.09.1
uses: &home-assistant-wheels home-assistant/wheels@2025.10.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
Expand Down
1 change: 0 additions & 1 deletion .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@ homeassistant.components.efergy.*
homeassistant.components.eheimdigital.*
homeassistant.components.electrasmart.*
homeassistant.components.electric_kiwi.*
homeassistant.components.elevenlabs.*
homeassistant.components.elgato.*
homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*
Expand Down
72 changes: 63 additions & 9 deletions homeassistant/components/demo/valve.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
from __future__ import annotations

import asyncio
from datetime import datetime
from typing import Any

from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_utc_time_change

OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend

Expand All @@ -23,6 +25,8 @@ async def async_setup_entry(
[
DemoValve("Front Garden", ValveState.OPEN),
DemoValve("Orchard", ValveState.CLOSED),
DemoValve("Back Garden", ValveState.CLOSED, position=70),
DemoValve("Trees", ValveState.CLOSED, position=30),
]
)

Expand All @@ -37,6 +41,7 @@ def __init__(
name: str,
state: str,
moveable: bool = True,
position: int | None = None,
) -> None:
"""Initialize the valve."""
self._attr_name = name
Expand All @@ -46,11 +51,23 @@ def __init__(
)
self._state = state
self._moveable = moveable
self._attr_reports_position = False
self._unsub_listener_valve: CALLBACK_TYPE | None = None
self._set_position: int = 0
self._position: int = 0
if position is None:
return

self._position = self._set_position = position
self._attr_reports_position = True
self._attr_supported_features |= (
ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP
)

@property
def is_open(self) -> bool:
"""Return true if valve is open."""
return self._state == ValveState.OPEN
def current_valve_position(self) -> int:
"""Return current position of valve."""
return self._position

@property
def is_opening(self) -> bool:
Expand All @@ -67,11 +84,6 @@ def is_closed(self) -> bool:
"""Return true if valve is closed."""
return self._state == ValveState.CLOSED

@property
def reports_position(self) -> bool:
"""Return True if entity reports position, False otherwise."""
return False

async def async_open_valve(self, **kwargs: Any) -> None:
"""Open the valve."""
self._state = ValveState.OPENING
Expand All @@ -87,3 +99,45 @@ async def async_close_valve(self, **kwargs: Any) -> None:
await asyncio.sleep(OPEN_CLOSE_DELAY)
self._state = ValveState.CLOSED
self.async_write_ha_state()

async def async_stop_valve(self) -> None:
"""Stop the valve."""
self._state = ValveState.OPEN if self._position > 0 else ValveState.CLOSED
if self._unsub_listener_valve is not None:
self._unsub_listener_valve()
self._unsub_listener_valve = None
self.async_write_ha_state()

async def async_set_valve_position(self, position: int) -> None:
"""Move the valve to a specific position."""
if position == self._position:
return
if position > self._position:
self._state = ValveState.OPENING
else:
self._state = ValveState.CLOSING

self._set_position = round(position, -1)
self._listen_valve()
self.async_write_ha_state()

@callback
def _listen_valve(self) -> None:
"""Listen for changes in valve."""
if self._unsub_listener_valve is None:
self._unsub_listener_valve = async_track_utc_time_change(
self.hass, self._time_changed_valve
)

async def _time_changed_valve(self, now: datetime) -> None:
"""Track time changes."""
if self._state == ValveState.OPENING:
self._position += 10
elif self._state == ValveState.CLOSING:
self._position -= 10

if self._position in (100, 0, self._set_position):
await self.async_stop_valve()
return

self.async_write_ha_state()
3 changes: 3 additions & 0 deletions homeassistant/components/elevenlabs/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
DEFAULT_STYLE = 0
DEFAULT_USE_SPEAKER_BOOST = True

MAX_REQUEST_IDS = 3
MODELS_PREVIOUS_INFO_NOT_SUPPORTED = ("eleven_v3",)

STT_LANGUAGES = [
"af-ZA", # Afrikaans
"am-ET", # Amharic
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/elevenlabs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["elevenlabs"],
"requirements": ["elevenlabs==2.3.0"]
"requirements": ["elevenlabs==2.3.0", "sentence-stream==1.2.0"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/elevenlabs/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
strict-typing: todo
167 changes: 164 additions & 3 deletions homeassistant/components/elevenlabs/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@

from __future__ import annotations

from collections.abc import Mapping
import asyncio
from collections import deque
from collections.abc import AsyncGenerator, Mapping
import contextlib
import logging
from typing import Any

from elevenlabs import AsyncElevenLabs
from elevenlabs.core import ApiError
from elevenlabs.types import Model, Voice as ElevenLabsVoice, VoiceSettings
from sentence_stream import SentenceBoundaryDetector

from homeassistant.components.tts import (
ATTR_VOICE,
TextToSpeechEntity,
TTSAudioRequest,
TTSAudioResponse,
TtsAudioType,
Voice,
)
Expand All @@ -35,10 +41,12 @@
DEFAULT_STYLE,
DEFAULT_USE_SPEAKER_BOOST,
DOMAIN,
MAX_REQUEST_IDS,
MODELS_PREVIOUS_INFO_NOT_SUPPORTED,
)

_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
PARALLEL_UPDATES = 6


def to_voice_settings(options: Mapping[str, Any]) -> VoiceSettings:
Expand Down Expand Up @@ -122,7 +130,12 @@ def __init__(
self._attr_supported_languages = [
lang.language_id for lang in self._model.languages or []
]
self._attr_default_language = self.supported_languages[0]
# Use the first supported language as the default if available
self._attr_default_language = (
self._attr_supported_languages[0]
if self._attr_supported_languages
else "en"
)

def async_get_supported_voices(self, language: str) -> list[Voice]:
"""Return a list of supported voices for a language."""
Expand Down Expand Up @@ -151,3 +164,151 @@ async def async_get_tts_audio(
)
raise HomeAssistantError(exc) from exc
return "mp3", bytes_combined

async def async_stream_tts_audio(
self, request: TTSAudioRequest
) -> TTSAudioResponse:
"""Generate speech from an incoming message."""
_LOGGER.debug(
"Getting TTS audio for language %s and options: %s",
request.language,
request.options,
)
return TTSAudioResponse("mp3", self._process_tts_stream(request))

async def _process_tts_stream(
self, request: TTSAudioRequest
) -> AsyncGenerator[bytes]:
"""Generate speech from an incoming message."""
text_stream = request.message_gen
boundary_detector = SentenceBoundaryDetector()
sentences: list[str] = []
sentences_ready = asyncio.Event()
sentences_complete = False

language_code: str | None = request.language
voice_id = request.options.get(ATTR_VOICE, self._default_voice_id)
model = request.options.get(ATTR_MODEL, self._model.model_id)

use_request_ids = model not in MODELS_PREVIOUS_INFO_NOT_SUPPORTED
previous_request_ids: deque[str] = deque(maxlen=MAX_REQUEST_IDS)

base_stream_params = {
"voice_id": voice_id,
"model_id": model,
"output_format": "mp3_44100_128",
"voice_settings": self._voice_settings,
}
if language_code:
base_stream_params["language_code"] = language_code

_LOGGER.debug("Starting TTS Stream with options: %s", base_stream_params)

async def _add_sentences() -> None:
nonlocal sentences_complete

try:
# Text chunks may not be on word or sentence boundaries
async for text_chunk in text_stream:
for sentence in boundary_detector.add_chunk(text_chunk):
if not sentence.strip():
continue

sentences.append(sentence)

if not sentences:
continue

sentences_ready.set()

# Final sentence
if text := boundary_detector.finish():
sentences.append(text)
finally:
sentences_complete = True
sentences_ready.set()

_add_sentences_task = self.hass.async_create_background_task(
_add_sentences(), name="elevenlabs_tts_add_sentences"
)

# Process new sentences as they're available, but synthesize the first
# one immediately. While that's playing, synthesize (up to) the next 3
# sentences. After that, synthesize all completed sentences as they're
# available.
sentence_schedule = [1, 3]
while True:
await sentences_ready.wait()

# Don't wait again if no more sentences are coming
if not sentences_complete:
sentences_ready.clear()

if not sentences:
if sentences_complete:
# Exit TTS loop
_LOGGER.debug("No more sentences to process")
break

# More sentences may be coming
continue

new_sentences = sentences[:]
sentences.clear()

while new_sentences:
if sentence_schedule:
max_sentences = sentence_schedule.pop(0)
sentences_to_process = new_sentences[:max_sentences]
new_sentences = new_sentences[len(sentences_to_process) :]
else:
# Process all available sentences together
sentences_to_process = new_sentences[:]
new_sentences.clear()

# Combine all new sentences completed to this point
text = " ".join(sentences_to_process).strip()

if not text:
continue

# Build kwargs common to both modes
kwargs = base_stream_params | {
"text": text,
}

# Provide previous_request_ids if supported.
if previous_request_ids:
# Send previous request ids.
kwargs["previous_request_ids"] = list(previous_request_ids)

# Synthesize audio while text chunks are still being accumulated
_LOGGER.debug("Synthesizing TTS for text: %s", text)
try:
async with self._client.text_to_speech.with_raw_response.stream(
**kwargs
) as stream:
async for chunk_bytes in stream.data:
yield chunk_bytes

if use_request_ids:
if (rid := stream.headers.get("request-id")) is not None:
previous_request_ids.append(rid)
else:
_LOGGER.debug(
"No request-id returned from server; clearing previous requests"
)
previous_request_ids.clear()
except ApiError as exc:
_LOGGER.warning(
"Error during processing of TTS request %s", exc, exc_info=True
)
_add_sentences_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _add_sentences_task
raise HomeAssistantError(exc) from exc

# Capture and store server request-id for next calls (only when supported)
_LOGGER.debug("Completed TTS stream for text: %s", text)

_LOGGER.debug("Completed TTS stream")
Loading
Loading