Menhaus commited on
Commit
09e5aba
·
verified ·
1 Parent(s): 44ec7d8

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +19 -8
  2. app.py +148 -5
  3. test_app.py +60 -0
README.md CHANGED
@@ -9,14 +9,17 @@ pinned: false
9
 
10
  FastAPI-сервис для Hugging Face Docker Space, который заменяет тяжёлый Qwen TTS на более лёгкий CPU-стек:
11
 
12
- - `POST /podcast-script` — LLM-сценарист подкаста строго по фактам маршрута.
13
- - `POST /tts` — короткая русская озвучка через Piper.
14
 
15
  ## Что важно
16
 
17
  - Space остаётся тем же: можно обновить файлы существующего `menhaus-velosearch`.
18
- - Цель v1 — стабильность на CPU Space, а не максимальная натуральность голоса.
19
  - LLM не должна выдумывать факты: prompt требует использовать только данные из запроса.
 
 
 
20
  - На момент проверки репозиторий `rhasspy/piper-voices` указывает лицензию MIT, но для релиза стоит сохранить ссылку на карточку и файл лицензии выбранного голоса.
21
  - Если LLM или TTS недоступны, клиент должен использовать шаблонный подкаст и браузерный голос.
22
 
@@ -30,11 +33,13 @@ FastAPI-сервис для Hugging Face Docker Space, который замен
30
  | `PODCAST_LLM_MAX_FACTS` | `4` | Максимум точек маршрута в одном сценарии |
31
  | `PODCAST_LLM_MAX_INPUT_CHARS` | `7000` | Лимит prompt-пакета фактов |
32
  | `PODCAST_LLM_MAX_NEW_TOKENS` | `420` | Лимит ответа LLM |
 
33
  | `TTS_ENGINE` | `piper` | Сейчас поддерживается `piper` |
34
- | `TTS_MAX_TEXT_CHARS` | `420` | Лимит одной TTS-реплики |
 
 
35
  | `PIPER_VOICE_REPO` | `rhasspy/piper-voices` | Репозиторий голосов Piper |
36
- | `PIPER_VOICE_BASE` | `ru/ru_RU/dmitri/medium` | Папка голоса в репозитории |
37
- | `PIPER_VOICE_NAME` | `ru_RU-dmitri-medium` | Имя `.onnx` и `.onnx.json` файлов |
38
 
39
  ## API
40
 
@@ -82,6 +87,8 @@ Content-Type: application/json
82
  }
83
  ```
84
 
 
 
85
  ```http
86
  POST /tts
87
  Content-Type: application/json
@@ -89,12 +96,13 @@ Content-Type: application/json
89
  {
90
  "text": "Короткий текст на русском",
91
  "role": "guide",
 
92
  "speed": 1,
93
  "format": "wav"
94
  }
95
  ```
96
 
97
- Ответ: `audio/wav` или `audio/ogg`.
98
 
99
  ## Локальная проверка
100
 
@@ -117,5 +125,8 @@ curl -X POST http://127.0.0.1:7860/podcast-script ^
117
  curl -X POST http://127.0.0.1:7860/tts ^
118
  -H "Content-Type: application/json" ^
119
  -o sample.wav ^
120
- -d "{\"text\":\"Привет! Это тест аудиогида.\",\"role\":\"guide\",\"format\":\"wav\"}"
121
  ```
 
 
 
 
9
 
10
  FastAPI-сервис для Hugging Face Docker Space, который заменяет тяжёлый Qwen TTS на более лёгкий CPU-стек:
11
 
12
+ - `POST /podcast-script` — быстрый фактологический сценарий по данным маршрута; LLM можно включить отдельно на более мощном hardware.
13
+ - `POST /tts` — русская озвучка через Piper, по умолчанию женским голосом `ru_RU-irina-medium`; длинная допустимая реплика синтезируется полностью.
14
 
15
  ## Что важно
16
 
17
  - Space остаётся тем же: можно обновить файлы существующего `menhaus-velosearch`.
18
+ - Цель v1 — стабильность на бесплатном CPU Space, поэтому по умолчанию подкаст собирается шаблонно по фактам без ожидания LLM.
19
  - LLM не должна выдумывать факты: prompt требует использовать только данные из запроса.
20
+ - Основной голос аудиогида — женский Piper `ru_RU-irina-medium`; `ru_RU-dmitri-medium` оставлен в allowlist как технический fallback.
21
+ - Перед Piper применяется произносительная нормализация: адресные формы (`г.`, `ул.`, `д.`) и единицы маршрута раскрываются для речи, не изменяя исходные данные приложения.
22
+ - Реплика длиннее внутреннего чанка синтезируется по частям и возвращается одним WAV/OGG-файлом, поэтому клиент получает одно событие завершения речи.
23
  - На момент проверки репозиторий `rhasspy/piper-voices` указывает лицензию MIT, но для релиза стоит сохранить ссылку на карточку и файл лицензии выбранного голоса.
24
  - Если LLM или TTS недоступны, клиент должен использовать шаблонный подкаст и браузерный голос.
25
 
 
33
  | `PODCAST_LLM_MAX_FACTS` | `4` | Максимум точек маршрута в одном сценарии |
34
  | `PODCAST_LLM_MAX_INPUT_CHARS` | `7000` | Лимит prompt-пакета фактов |
35
  | `PODCAST_LLM_MAX_NEW_TOKENS` | `420` | Лимит ответа LLM |
36
+ | `PODCAST_LLM_ENABLED` | `false` | Включить LLM-сценарист; для `CPU Basic` рекомендуется оставить `false` |
37
  | `TTS_ENGINE` | `piper` | Сейчас поддерживается `piper` |
38
+ | `TTS_MAX_REQUEST_CHARS` | `1800` | Максимальная длина полного текста одного TTS-запроса; при превышении возвращается ошибка |
39
+ | `TTS_CHUNK_MAX_CHARS` | `420` | Внутренний размер фрагмента Piper; полный аудиоответ склеивается из всех фрагментов |
40
+ | `TTS_RUSSIAN_NORMALIZATION_ENABLED` | `true` | Раскрывать русские адресные сокращения и единицы перед синтезом |
41
  | `PIPER_VOICE_REPO` | `rhasspy/piper-voices` | Репозиторий голосов Piper |
42
+ | `PIPER_DEFAULT_VOICE` | `ru_RU-irina-medium` | Основной голос из разрешённого списка |
 
43
 
44
  ## API
45
 
 
87
  }
88
  ```
89
 
90
+ На бесплатном `CPU Basic` ответ по умолчанию содержит `provider: template-fallback` и формируется без загрузки Qwen. После перевода Space на более мощный hardware можно установить переменную окружения `PODCAST_LLM_ENABLED=true` и сравнить время ответа.
91
+
92
  ```http
93
  POST /tts
94
  Content-Type: application/json
 
96
  {
97
  "text": "Короткий текст на русском",
98
  "role": "guide",
99
+ "voice": "ru_RU-irina-medium",
100
  "speed": 1,
101
  "format": "wav"
102
  }
103
  ```
104
 
105
+ Ответ: `audio/wav` или `audio/ogg`; заголовок `X-TTS-Voice` сообщает фактически выбранный голос. Допустимые значения `voice`: `ru_RU-irina-medium`, `ru_RU-dmitri-medium`. Например, текст `г. Иваново, ул. Ленина, д. 4, 3 км` в серверной озвучке произносится в раскрытом русском виде, но исходная строка в UI не меняется.
106
 
107
  ## Локальная проверка
108
 
 
125
  curl -X POST http://127.0.0.1:7860/tts ^
126
  -H "Content-Type: application/json" ^
127
  -o sample.wav ^
128
+ -d "{\"text\":\"Привет! Это тест аудиогида.\",\"role\":\"guide\",\"voice\":\"ru_RU-irina-medium\",\"format\":\"wav\"}"
129
  ```
130
+
131
+ Для проверки выбранного голоса используйте ответ с заголовками и убедитесь, что сервис возвращает
132
+ `X-TTS-Voice: ru_RU-irina-medium`.
app.py CHANGED
@@ -32,7 +32,9 @@ LLM_MAX_NEW_TOKENS = int(os.getenv('PODCAST_LLM_MAX_NEW_TOKENS', '420'))
32
  PODCAST_LLM_ENABLED = os.getenv('PODCAST_LLM_ENABLED', 'false').lower() in {'1', 'true', 'yes', 'on'}
33
 
34
  TTS_ENGINE = os.getenv('TTS_ENGINE', 'piper').lower()
35
- TTS_MAX_TEXT_CHARS = int(os.getenv('TTS_MAX_TEXT_CHARS', '420'))
 
 
36
  PIPER_VOICE_REPO = os.getenv('PIPER_VOICE_REPO', 'rhasspy/piper-voices')
37
  PIPER_DEFAULT_VOICE = os.getenv('PIPER_DEFAULT_VOICE', 'ru_RU-irina-medium')
38
  PIPER_FALLBACK_VOICE = 'ru_RU-dmitri-medium'
@@ -128,12 +130,131 @@ def clean_text(value: str | None, limit: int = 900) -> str:
128
  return shortened.rsplit(' ', 1)[0].strip()
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  def normalize_tts_text(text: str) -> str:
132
- normalized = clean_text(text, TTS_MAX_TEXT_CHARS)
133
  if not normalized:
134
  raise HTTPException(status_code=400, detail='Text is required.')
135
 
136
- return normalized
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
 
139
  def resolve_piper_voice(voice: str, role: str) -> str:
@@ -536,7 +657,7 @@ def piper_paths(voice_name: str) -> tuple[str, str]:
536
  return model_path, config_path
537
 
538
 
539
- def synthesize_with_piper(text: str, speed: float, voice_name: str) -> tuple[np.ndarray, int]:
540
  model_path, config_path = piper_paths(voice_name)
541
  with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as wav_file:
542
  wav_path = wav_file.name
@@ -595,6 +716,25 @@ def synthesize_with_piper(text: str, speed: float, voice_name: str) -> tuple[np.
595
  pass
596
 
597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  def write_audio(audio: np.ndarray, sample_rate: int, requested_format: str) -> tuple[bytes, str]:
599
  buffer = io.BytesIO()
600
  requested_format = requested_format.lower()
@@ -627,6 +767,7 @@ def health():
627
  'podcast_llm_enabled': PODCAST_LLM_ENABLED,
628
  'tts_engine': TTS_ENGINE,
629
  'tts_voice': PIPER_DEFAULT_VOICE if TTS_ENGINE == 'piper' else 'disabled',
 
630
  }
631
 
632
 
@@ -648,7 +789,9 @@ def diagnostics():
648
  'last_script_error': LAST_SCRIPT_ERROR,
649
  'last_script_error_type': LAST_SCRIPT_ERROR_TYPE,
650
  'tts_engine': TTS_ENGINE,
651
- 'tts_max_text_chars': TTS_MAX_TEXT_CHARS,
 
 
652
  'piper_voice_repo': PIPER_VOICE_REPO,
653
  'piper_default_voice': PIPER_DEFAULT_VOICE,
654
  'piper_allowed_voices': list(PIPER_VOICES.keys()),
 
32
  PODCAST_LLM_ENABLED = os.getenv('PODCAST_LLM_ENABLED', 'false').lower() in {'1', 'true', 'yes', 'on'}
33
 
34
  TTS_ENGINE = os.getenv('TTS_ENGINE', 'piper').lower()
35
+ TTS_MAX_REQUEST_CHARS = int(os.getenv('TTS_MAX_REQUEST_CHARS', os.getenv('TTS_MAX_TEXT_CHARS', '1800')))
36
+ TTS_CHUNK_MAX_CHARS = int(os.getenv('TTS_CHUNK_MAX_CHARS', '420'))
37
+ TTS_RUSSIAN_NORMALIZATION_ENABLED = os.getenv('TTS_RUSSIAN_NORMALIZATION_ENABLED', 'true').lower() in {'1', 'true', 'yes', 'on'}
38
  PIPER_VOICE_REPO = os.getenv('PIPER_VOICE_REPO', 'rhasspy/piper-voices')
39
  PIPER_DEFAULT_VOICE = os.getenv('PIPER_DEFAULT_VOICE', 'ru_RU-irina-medium')
40
  PIPER_FALLBACK_VOICE = 'ru_RU-dmitri-medium'
 
130
  return shortened.rsplit(' ', 1)[0].strip()
131
 
132
 
133
+ def clean_full_text(value: str | None) -> str:
134
+ if not value:
135
+ return ''
136
+
137
+ text = re.sub(r'[\x00-\x1f]+', ' ', str(value))
138
+ return re.sub(r'\s+', ' ', text).strip()
139
+
140
+
141
+ def russian_number_unit(raw_number: str, forms: tuple[str, str, str]) -> str:
142
+ spoken_number = raw_number.replace('.', ',')
143
+ if ',' in spoken_number:
144
+ return f'{spoken_number} {forms[1]}'
145
+
146
+ value = int(spoken_number)
147
+ remainder_100 = value % 100
148
+ remainder_10 = value % 10
149
+ if 11 <= remainder_100 <= 14:
150
+ form = forms[2]
151
+ elif remainder_10 == 1:
152
+ form = forms[0]
153
+ elif 2 <= remainder_10 <= 4:
154
+ form = forms[1]
155
+ else:
156
+ form = forms[2]
157
+ return f'{spoken_number} {form}'
158
+
159
+
160
+ def normalize_spoken_russian(text: str) -> str:
161
+ if not TTS_RUSSIAN_NORMALIZATION_ENABLED:
162
+ return text
163
+
164
+ normalized = text
165
+ address_rules = [
166
+ (r'\bв\s+г\.\s*(?=[А-ЯЁ])', 'в городе '),
167
+ (r'\bиз\s+г\.\s*(?=[А-ЯЁ])', 'из города '),
168
+ (r'\bпо\s+г\.\s*(?=[А-ЯЁ])', 'по городу '),
169
+ (r'\bг\.\s*(?=[А-ЯЁ])', 'город '),
170
+ (r'\bна\s+ул\.\s*', 'на улице '),
171
+ (r'\bпо\s+ул\.\s*', 'по улице '),
172
+ (r'\bс\s+ул\.\s*', 'с улицы '),
173
+ (r'\bул\.\s*', 'улица '),
174
+ (r'\bна\s+(?:пр-т|просп\.)\s*', 'на проспекте '),
175
+ (r'\bпо\s+(?:пр-т|просп\.)\s*', 'по проспекту '),
176
+ (r'\b(?:пр-т|просп\.)\s*', 'проспект '),
177
+ (r'\bв\s+пер\.\s*', 'в переулке '),
178
+ (r'\bпо\s+пер\.\s*', 'по переулку '),
179
+ (r'\bпер\.\s*', 'переулок '),
180
+ (r'\bна\s+пл\.\s*', 'на площади '),
181
+ (r'\bпл\.\s*', 'площадь '),
182
+ (r'\bд\.\s*(?=\d)', 'дом '),
183
+ (r'\bкорп\.\s*(?=\d)', 'корпус '),
184
+ (r'\bстр\.\s*(?=\d)', 'строение '),
185
+ ]
186
+ normalized = re.sub(r'\bв\s+(\d{3,4})\s*г\.\s*', r'в \1 году ', normalized)
187
+ normalized = re.sub(r'\b(\d{3,4})\s*г\.\s*', r'\1 год ', normalized)
188
+ for pattern, replacement in address_rules:
189
+ normalized = re.sub(pattern, replacement, normalized, flags=re.IGNORECASE)
190
+
191
+ unit_rules = [
192
+ (r'(\d+(?:[.,]\d+)?)\s*км\s*/\s*ч\b', ('километр в час', 'километра в час', 'километров в час')),
193
+ (r'(\d+(?:[.,]\d+)?)\s*км\b', ('километр', 'километра', 'километров')),
194
+ (r'(\d+(?:[.,]\d+)?)\s*мин\b', ('минута', 'минуты', 'минут')),
195
+ (r'(\d+(?:[.,]\d+)?)\s*ч\b', ('час', 'часа', 'часов')),
196
+ (r'(\d+(?:[.,]\d+)?)\s*м\b', ('метр', 'метра', 'метров')),
197
+ ]
198
+ for pattern, forms in unit_rules:
199
+ normalized = re.sub(pattern, lambda match: russian_number_unit(match.group(1), forms), normalized)
200
+ return re.sub(r'\s+', ' ', normalized).strip()
201
+
202
+
203
  def normalize_tts_text(text: str) -> str:
204
+ normalized = clean_full_text(text)
205
  if not normalized:
206
  raise HTTPException(status_code=400, detail='Text is required.')
207
 
208
+ if len(normalized) > TTS_MAX_REQUEST_CHARS:
209
+ raise HTTPException(status_code=400, detail='TTS text exceeds maximum request length.')
210
+
211
+ return normalize_spoken_russian(normalized)
212
+
213
+
214
+ def split_tts_chunks(text: str, max_chars: int = TTS_CHUNK_MAX_CHARS) -> list[str]:
215
+ if max_chars < 20:
216
+ raise ValueError('TTS chunk size must be at least 20 characters.')
217
+
218
+ chunks: list[str] = []
219
+ current = ''
220
+ phrases = re.findall(r'.+?(?:[.!?;:]+(?=\s|$)|$)', text)
221
+ for phrase in phrases:
222
+ phrase = phrase.strip()
223
+ if not phrase:
224
+ continue
225
+ if len(phrase) > max_chars:
226
+ words = phrase.split()
227
+ pieces: list[str] = []
228
+ part = ''
229
+ for word in words:
230
+ if len(word) > max_chars:
231
+ if part:
232
+ pieces.append(part)
233
+ part = ''
234
+ # An unusually long token is preserved rather than read as broken fragments.
235
+ pieces.append(word)
236
+ elif not part or len(part) + len(word) + 1 <= max_chars:
237
+ part = f'{part} {word}'.strip()
238
+ else:
239
+ pieces.append(part)
240
+ part = word
241
+ if part:
242
+ pieces.append(part)
243
+ else:
244
+ pieces = [phrase]
245
+
246
+ for piece in pieces:
247
+ if not current:
248
+ current = piece
249
+ elif len(current) + len(piece) + 1 <= max_chars:
250
+ current = f'{current} {piece}'
251
+ else:
252
+ chunks.append(current)
253
+ current = piece
254
+
255
+ if current:
256
+ chunks.append(current)
257
+ return chunks or [text]
258
 
259
 
260
  def resolve_piper_voice(voice: str, role: str) -> str:
 
657
  return model_path, config_path
658
 
659
 
660
+ def synthesize_piper_chunk(text: str, speed: float, voice_name: str) -> tuple[np.ndarray, int]:
661
  model_path, config_path = piper_paths(voice_name)
662
  with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as wav_file:
663
  wav_path = wav_file.name
 
716
  pass
717
 
718
 
719
+ def synthesize_with_piper(text: str, speed: float, voice_name: str) -> tuple[np.ndarray, int]:
720
+ chunks = split_tts_chunks(text, TTS_CHUNK_MAX_CHARS)
721
+ rendered_chunks: list[np.ndarray] = []
722
+ sample_rate = DEFAULT_SAMPLE_RATE
723
+
724
+ for index, chunk in enumerate(chunks):
725
+ chunk_audio, chunk_sample_rate = synthesize_piper_chunk(chunk, speed, voice_name)
726
+ if index == 0:
727
+ sample_rate = chunk_sample_rate
728
+ elif chunk_sample_rate != sample_rate:
729
+ raise HTTPException(status_code=502, detail='Piper returned mismatched audio sample rates.')
730
+
731
+ rendered_chunks.append(np.asarray(chunk_audio, dtype='float32'))
732
+ if index < len(chunks) - 1:
733
+ rendered_chunks.append(np.zeros(max(1, int(sample_rate * 0.06)), dtype='float32'))
734
+
735
+ return np.concatenate(rendered_chunks), sample_rate
736
+
737
+
738
  def write_audio(audio: np.ndarray, sample_rate: int, requested_format: str) -> tuple[bytes, str]:
739
  buffer = io.BytesIO()
740
  requested_format = requested_format.lower()
 
767
  'podcast_llm_enabled': PODCAST_LLM_ENABLED,
768
  'tts_engine': TTS_ENGINE,
769
  'tts_voice': PIPER_DEFAULT_VOICE if TTS_ENGINE == 'piper' else 'disabled',
770
+ 'tts_russian_normalization_enabled': TTS_RUSSIAN_NORMALIZATION_ENABLED,
771
  }
772
 
773
 
 
789
  'last_script_error': LAST_SCRIPT_ERROR,
790
  'last_script_error_type': LAST_SCRIPT_ERROR_TYPE,
791
  'tts_engine': TTS_ENGINE,
792
+ 'tts_max_request_chars': TTS_MAX_REQUEST_CHARS,
793
+ 'tts_chunk_max_chars': TTS_CHUNK_MAX_CHARS,
794
+ 'tts_russian_normalization_enabled': TTS_RUSSIAN_NORMALIZATION_ENABLED,
795
  'piper_voice_repo': PIPER_VOICE_REPO,
796
  'piper_default_voice': PIPER_DEFAULT_VOICE,
797
  'piper_allowed_voices': list(PIPER_VOICES.keys()),
test_app.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ import numpy as np
5
+ from fastapi import HTTPException
6
+
7
+ import app
8
+
9
+
10
+ class SpokenRussianTests(unittest.TestCase):
11
+ def test_address_forms_are_expanded_for_speech(self):
12
+ text = 'В г. Иваново, на ул. Ленина, д. 4 и корп. 2.'
13
+
14
+ self.assertEqual(
15
+ app.normalize_spoken_russian(text),
16
+ 'В городе Иваново, на улице Ленина, дом 4 и корпус 2.',
17
+ )
18
+
19
+ def test_route_units_are_expanded(self):
20
+ text = 'Маршрут: 3 км, 250 м, 21 мин, скорость 12 км/ч.'
21
+
22
+ self.assertEqual(
23
+ app.normalize_spoken_russian(text),
24
+ 'Маршрут: 3 километра, 250 метров, 21 минута, скорость 12 километров в час.',
25
+ )
26
+
27
+ def test_request_over_limit_fails_instead_of_truncating(self):
28
+ with self.assertRaises(HTTPException):
29
+ app.normalize_tts_text('а' * (app.TTS_MAX_REQUEST_CHARS + 1))
30
+
31
+
32
+ class PiperChunkTests(unittest.TestCase):
33
+ def test_long_text_keeps_tail_in_last_chunk(self):
34
+ text = 'Первая фраза достаточно длинная. Финальная фраза должна прозвучать полностью.'
35
+ chunks = app.split_tts_chunks(text, 35)
36
+
37
+ self.assertGreater(len(chunks), 1)
38
+ self.assertIn('полностью.', chunks[-1])
39
+ self.assertEqual(' '.join(chunks), text)
40
+
41
+ def test_multiple_chunks_are_returned_as_one_audio_array(self):
42
+ with patch.object(app, 'TTS_CHUNK_MAX_CHARS', 30), patch.object(
43
+ app,
44
+ 'synthesize_piper_chunk',
45
+ side_effect=[(np.array([0.1, 0.2], dtype='float32'), 100), (np.array([0.3], dtype='float32'), 100)],
46
+ ) as synthesizer:
47
+ audio, sample_rate = app.synthesize_with_piper(
48
+ 'Первая длинная реплика. Вторая длинная реплика.',
49
+ 1.0,
50
+ 'ru_RU-irina-medium',
51
+ )
52
+
53
+ self.assertEqual(synthesizer.call_count, 2)
54
+ self.assertEqual(sample_rate, 100)
55
+ self.assertGreater(len(audio), 3)
56
+ self.assertAlmostEqual(float(audio[-1]), 0.3, places=6)
57
+
58
+
59
+ if __name__ == '__main__':
60
+ unittest.main()