Skip to content

Commit 89e38d5

Browse files
POC: Voice Announcement of tokens using audio fragments (#12)
1 parent 3ca1c58 commit 89e38d5

84 files changed

Lines changed: 740 additions & 28 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.html

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ recursive-exclude * *.py[co]
99

1010
recursive-include docs *.md Makefile *.jpg *.png *.gif
1111
recursive-include src/token_display/templates *
12+
recursive-include src/token_display/static *

docs/installation.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,11 @@ Once you have a copy of the source, you can install it with:
3030
cd token_display
3131
pip install .
3232
```
33+
34+
## Audio announcements
35+
36+
Audio is assembled in the browser from pre-recorded WAV fragments shipped
37+
with the plugin. There are no additional Python dependencies, no system
38+
packages (e.g. `ffmpeg`, `espeak`) and no server-side text-to-speech
39+
required. See [Audio announcements in *Usage*](usage.md#audio-announcements)
40+
for how to replace the placeholder voice.

docs/usage.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,127 @@ To use care_token_display in a project:
55
```python
66
import token_display
77
```
8+
9+
## Audio announcements
10+
11+
The display calls out new tokens with a chime followed by a pre-recorded voice
12+
prompt that spells the token code one character at a time. Audio is assembled
13+
**in the browser** using the Web Audio API from a flat folder of small WAV
14+
fragments shipped with the plugin — no server-side TTS, no extra dependencies,
15+
and no `SpeechSynthesis` (which is unreliable on signage hardware such as
16+
Samsung Tizen / MagicINFO).
17+
18+
A typical announcement plays as:
19+
20+
> *(chime)* "Now serving token, G, zero, zero, one"
21+
22+
The "Now serving token" prefix can be played in multiple languages back to
23+
back for the same token — see [Multi-language announcements](#multi-language-announcements).
24+
Tokens are deduplicated client-side via `localStorage`, so a given token is
25+
announced at most once per display across page refreshes.
26+
27+
### Query parameters
28+
29+
| Param | Default | Description |
30+
| --------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31+
| `va_lang` | `VA_DEFAULT_LANG` setting | Comma-separated language codes for the prefix announcement (e.g. `en_IN`, `ml_IN,en_IN`). Each code must have a matching `prefix-<code>.wav`. Pass `?va_lang=` (empty) to mute. |
32+
33+
### Muting the voice announcer
34+
35+
The voice announcer is muted by resolving to an empty list of languages. Two
36+
ways to do this:
37+
38+
- Per-request: pass `?va_lang=` with no value (or only invalid codes).
39+
- Globally: configure `VA_DEFAULT_LANG = []` in your plugin settings.
40+
41+
When muted, the page is rendered without the announcer payload or script —
42+
no `localStorage` writes, no fragment fetches — and a plain
43+
`<meta http-equiv="refresh">` drives the periodic reload.
44+
45+
### Multi-language announcements
46+
47+
The chime is language-neutral and reused as-is. Everything else — the
48+
*"Now serving token"* prefix and the per-character utterances — is recorded
49+
per language under a `<lang>/` subdirectory (for example `en_IN/prefix.wav`,
50+
`en_IN/A.wav`, `ml_IN/prefix.wav`, `ml_IN/A.wav`).
51+
52+
The playback order is controlled by:
53+
54+
1. The `?va_lang=` query parameter, if present (comma-separated, e.g.
55+
`?va_lang=ml_IN,en_IN`).
56+
2. Otherwise, the `VA_DEFAULT_LANG` plugin setting (default: `["en_IN"]`).
57+
58+
For each token, the announcer schedules every configured language pass
59+
back-to-back with a 1-second pause between languages:
60+
61+
```
62+
[chime] [ml_IN/prefix] [ml_IN/G] [ml_IN/0] [ml_IN/0] [ml_IN/1]
63+
... 1 second pause ...
64+
[chime] [en_IN/prefix] [en_IN/G] [en_IN/0] [en_IN/0] [en_IN/1]
65+
```
66+
67+
See [Muting the voice announcer](#muting-the-voice-announcer) for how to
68+
disable playback.
69+
70+
### Audio fragments
71+
72+
The fragments live under
73+
`src/token_display/static/token_display/sounds/`:
74+
75+
| File | Contents |
76+
| ----------------------------------- | --------------------------------------------------------------- |
77+
| `chime.wav` | Two-tone leading chime (language-neutral). |
78+
| `<lang>/prefix.wav` | "Now serving token" recorded in `<lang>` (e.g. `en_IN/prefix.wav`). |
79+
| `<lang>/A.wav``<lang>/Z.wav` | Each English letter pronounced in `<lang>`. |
80+
| `<lang>/0.wav``<lang>/9.wav` | Each digit pronounced in `<lang>`. |
81+
82+
So a configuration with `VA_DEFAULT_LANG = ["en_IN", "ml_IN"]` requires the
83+
`en_IN/` and `ml_IN/` subdirectories to each contain `prefix.wav`, `A.wav`
84+
`Z.wav` and `0.wav``9.wav`. `chime.wav` is shared.
85+
86+
All files must share the same sample format (the bundled placeholders are
87+
22.05 kHz mono 16-bit PCM, but the Web Audio API will resample anything it
88+
can decode). Each fragment should bake in a short trailing pause (~120 ms) so
89+
that consecutive characters do not slur together when concatenated.
90+
91+
### Replacing the placeholder voice
92+
93+
The shipped fragments are auto-generated placeholders using macOS's `say`
94+
utility (or stdlib tones on other platforms). To swap in real recordings:
95+
96+
1. Record one WAV per fragment using the same filenames as above.
97+
2. Use a single voice talent and a consistent peak level (~ -3 dBFS).
98+
3. Trim leading silence aggressively; leave ~120 ms of trailing silence.
99+
4. Drop the new files into
100+
`src/token_display/static/token_display/sounds/` — no code changes needed.
101+
102+
To regenerate the placeholders at any time, run:
103+
104+
```sh
105+
python scripts/generate_placeholder_fragments.py
106+
```
107+
108+
### Kiosk setup
109+
110+
Most browsers block autoplay until the user interacts with the page. For an
111+
unattended kiosk, launch Chromium with:
112+
113+
```shell
114+
chromium --kiosk \
115+
--autoplay-policy=no-user-gesture-required \
116+
"https://<your-care-host>/token_display/sub_queues/<sub_queue_external_ids>/?token=<api_token>"
117+
```
118+
119+
If the display is opened in a normal browser tab, click anywhere on the page
120+
once after loading to unlock audio.
121+
122+
### Graceful degradation
123+
124+
- If the Web Audio API is unavailable, the page renders silently and the
125+
currently shown tokens are recorded as "announced" so they do not loop on
126+
every refresh.
127+
- If a fragment fails to load or decode, the announcement is aborted, the
128+
affected tokens are still marked as announced, and the page reloads on
129+
schedule.
130+
- The `<meta http-equiv="refresh">` fallback still works with JavaScript
131+
disabled — the page refreshes on schedule but plays no audio.

pyproject.toml

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,20 @@ name = "token_display"
33
version = "0.1.0"
44
description = "A care plugin to SSR tokens of queues"
55
readme = "README.md"
6-
authors = [
7-
{name = "Open Healthcare Network", email = "support@ohc.network"}
8-
]
6+
authors = [{ name = "Open Healthcare Network", email = "support@ohc.network" }]
97
maintainers = [
10-
{name = "Open Healthcare Network", email = "support@ohc.network"}
8+
{ name = "Open Healthcare Network", email = "support@ohc.network" },
119
]
1210
classifiers = [
1311
# TODO
1412
]
15-
license = {text = "MIT"}
16-
dependencies = [
17-
"django",
18-
"celery",
19-
"djangorestframework",
20-
]
13+
license = { text = "MIT" }
14+
dependencies = ["django", "celery", "djangorestframework"]
2115
requires-python = ">= 3.10"
2216

2317
[project.optional-dependencies]
2418
test = [
25-
"ruff", # linting
19+
"ruff", # linting
2620
]
2721

2822
[project.urls]
@@ -36,12 +30,12 @@ line-length = 120
3630

3731
[tool.ruff.lint]
3832
select = [
39-
"E", # pycodestyle errors
40-
"W", # pycodestyle warnings
41-
"F", # Pyflakes
42-
"I", # isort
43-
"B", # flake8-bugbear
44-
"UP", # pyupgrade
33+
"E", # pycodestyle errors
34+
"W", # pycodestyle warnings
35+
"F", # Pyflakes
36+
"I", # isort
37+
"B", # flake8-bugbear
38+
"UP", # pyupgrade
4539
]
4640

4741
[tool.uv]

0 commit comments

Comments
 (0)