arcane-os 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.51.0
4
+
5
+ - Add synchronous native speech `provider.setDefaultVoice(voiceURI|null)`.
6
+ Update the live catalog and future preparations without replacing the
7
+ provider or altering explicit voices and prepared, queued or active speech.
8
+ Catalog subscriptions can apply an application-owned choice after voice
9
+ discovery; repeated values are silent and reentrant changes avoid stale
10
+ catalog delivery. Voice preference policy remains application-owned.
11
+ - Add `fetchSystemOneRequest` through `arcane-os/ai/twin-cloud` for explicit
12
+ model, complete caller-owned state and questions, and the complete native
13
+ System One response. Reuse the existing JSON transport, retry observers,
14
+ diagnostics and cancellation. Applications retain question definitions,
15
+ scoring policy, model selection and answer interpretation; no history,
16
+ fabricated tool calls, prompt conversion or new dependency is introduced.
17
+ - Keep all native recognition-language suggestions visible when the editable
18
+ picker opens or reopens. Filter only after text changes, show native language
19
+ and region labels, preserve exact custom BCP47 tags, and keep keyboard and
20
+ focus interaction in the shared speech control. The picker is available in
21
+ live-transcription mode before capture and disabled during capture or outside
22
+ that mode. Conversation language, Chat controls and capture payloads remain
23
+ unchanged.
24
+
3
25
  ## 0.50.0
4
26
 
5
27
  - Add `createBrowserSpeechSynthesisProvider` through `arcane-os/ai/browser-speech`.
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.50.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.51.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -101,10 +101,11 @@ export function createBrowserSpeechSynthesisProvider({id, model, language} = {})
101
101
 
102
102
  const modelId = model.id;
103
103
  const modelName = model.name;
104
- const defaultVoice = model.defaultVoice || null;
104
+ let defaultVoice = model.defaultVoice || null;
105
105
  const playbacks = new Set();
106
106
  const catalogObservers = new Set();
107
107
  let catalogSource = null;
108
+ let catalogRevision = 0;
108
109
  let state = 'unloaded';
109
110
  let generation = 0;
110
111
  let unloadOperation = null;
@@ -165,8 +166,25 @@ export function createBrowserSpeechSynthesisProvider({id, model, language} = {})
165
166
  }
166
167
 
167
168
  function publishCatalog() {
169
+ const revision = ++catalogRevision;
168
170
  const current = catalog();
169
- for (const observer of catalogObservers) observeCallback(observer, current);
171
+ for (const observer of catalogObservers) {
172
+ // A catalog observer can select a new default and publish its replacement.
173
+ if (revision !== catalogRevision) return;
174
+ observeCallback(observer, current);
175
+ }
176
+ }
177
+
178
+ function setDefaultVoice(voice = null) {
179
+ assertOpen();
180
+ if (voice !== null && !is.string(voice)) {
181
+ throw new TypeError('Browser speech synthesis default voice must be a string.');
182
+ }
183
+ const nextVoice = voice || null;
184
+ if (defaultVoice === nextVoice) return defaultVoice;
185
+ defaultVoice = nextVoice;
186
+ publishCatalog();
187
+ return defaultVoice;
170
188
  }
171
189
 
172
190
  function detachCatalog() {
@@ -485,6 +503,7 @@ export function createBrowserSpeechSynthesisProvider({id, model, language} = {})
485
503
  maxConcurrentRequests: 1,
486
504
  catalog,
487
505
  subscribeCatalog,
506
+ setDefaultVoice,
488
507
  status,
489
508
  prepare,
490
509
  play(payload, options = {}) {
@@ -3,6 +3,7 @@ import {arcaneLogging} from '../logging.mjs';
3
3
 
4
4
  const is = new Is(false);
5
5
  const twinChatURL = 'https://inference.do-ai.run/v1/chat/completions';
6
+ const twinSystemOneURL = 'https://inference.do-ai.run/v1/systemone';
6
7
 
7
8
  /** A stateless TWiN request; browser AI shares the HTTP and format owners below. */
8
9
  export async function fetchRequest({
@@ -87,6 +88,72 @@ export async function fetchRequest({
87
88
  }
88
89
  }
89
90
 
91
+ /** Submit caller-owned state and questions without interpreting the provider's answers. */
92
+ export async function fetchSystemOneRequest({
93
+ twinKey,
94
+ model,
95
+ state,
96
+ questions,
97
+ signal = null,
98
+ id = Date.now(),
99
+ onRequest = function observeSystemOneRequest(){},
100
+ onResponse = function observeSystemOneResponse(){},
101
+ onRetry = null
102
+ } = {}){
103
+ if(signal?.aborted){
104
+ throw normalizeAIRequestAbort(signal.reason);
105
+ }
106
+ if(!is.string(twinKey) || !twinKey){
107
+ const error = new Error('AI provider is not configured.');
108
+ error.code = 'AI_PROVIDER_NOT_CONFIGURED';
109
+ throw error;
110
+ }
111
+ if(!is.string(model) || !model){
112
+ throw new TypeError('TWiN fetchSystemOneRequest requires an explicit model.');
113
+ }
114
+
115
+ const request = {model, state, questions};
116
+ // Capture the caller's complete payload before a diagnostic observer runs.
117
+ const body = JSON.stringify(request);
118
+ try{
119
+ await onRequest(
120
+ request,
121
+ id,
122
+ {operation:'systemone', transport:'http', destination:twinSystemOneURL}
123
+ );
124
+ if(signal?.aborted){
125
+ throw normalizeAIRequestAbort(signal.reason);
126
+ }
127
+ const response = await fetchJSONResponse(
128
+ twinSystemOneURL,
129
+ {
130
+ method:'POST',
131
+ credentials:'omit',
132
+ headers:{
133
+ 'Content-Type':'application/json',
134
+ Authorization:`Bearer ${twinKey}`
135
+ },
136
+ body,
137
+ ...(signal ? {signal} : {})
138
+ },
139
+ {onRetry}
140
+ );
141
+ if(signal?.aborted){
142
+ throw normalizeAIRequestAbort(signal.reason);
143
+ }
144
+ await onResponse(response, id, false);
145
+ if(signal?.aborted){
146
+ throw normalizeAIRequestAbort(signal.reason);
147
+ }
148
+ return response;
149
+ }catch(error){
150
+ if(isAIRequestAbort(error, signal)){
151
+ throw normalizeAIRequestAbort(error);
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+
90
157
  export function isAIRequestAbort(error, signal){
91
158
  return signal?.aborted
92
159
  || error?.name === 'AbortError'
@@ -113,16 +113,35 @@ function playIntroduction(input, voice) {
113
113
  `catalog()` synchronously returns an array of model records. Each model has
114
114
  `speech:{playback:'native'}` and `voices:[{id,name,lang,default,localService}]`;
115
115
  `id` is the exact native `voiceURI`. `subscribeCatalog(callback)` immediately
116
- replays that same shape and updates it on `voiceschanged`, returning an
117
- unsubscribe function. An initially empty inventory is ordinary pending browser
116
+ replays that same shape and updates it on `voiceschanged` or a changed default,
117
+ returning an unsubscribe function. An initially empty inventory is ordinary pending browser
118
118
  state. There is no polling, discovery utterance, model download, or page-load
119
119
  barrier. Language selectors derive their choices from this actual inventory.
120
120
 
121
+ `provider.setDefaultVoice(voiceURI)` updates the provider's current
122
+ `catalog()[0].defaultVoice` synchronously and returns that current value.
123
+ The application may call it from its catalog subscription after asynchronous
124
+ voice discovery or when its saved selection changes. The SDK supplies no
125
+ voice-name preference: preserve an explicit saved choice in the application
126
+ and pass the exact native `voiceURI`. Repeating the current value is a no-op,
127
+ so a catalog subscription can apply the same choice without notification loops.
128
+ `null`, an empty string, or an omitted argument clears the configured default
129
+ and restores the existing browser-default behavior. A non-string, non-null
130
+ value throws `TypeError`; an unloading or disposed provider retains its
131
+ existing lifecycle error. A voice need not be present when setting the default:
132
+ the existing preparation/playback availability behavior below still applies.
133
+
134
+ Only future preparations use the new default. Explicit request voices and
135
+ already-prepared, queued, or active speech keep their selected voice. Updating
136
+ the default does not load, unload, replace, cancel, pause, resume, or speak.
137
+ AI's ordinary speech path reads this current catalog for new requests, so no
138
+ provider reconfiguration is necessary.
139
+
121
140
  `prepare({input,voice?,language?,speed?},{signal?})` returns an inert
122
141
  `{kind:'native-speech',input,voice,language,speed,play(options)}` descriptor.
123
142
  `play(payload,{signal,onState})` is the explicit-preview convenience. Both
124
- preserve complete input. Omitted voice uses the configured `model.defaultVoice`
125
- or the actual browser default; a supplied voice is never replaced with a named
143
+ preserve complete input. Omitted voice uses the current default, initially
144
+ `model.defaultVoice`, or the actual browser default; a supplied voice is never replaced with a named
126
145
  SDK alias. Language comes from that voice unless explicitly provided. Neither
127
146
  API reads the conversation's language preference. A saved voice waits for
128
147
  `voiceschanged` if the inventory is empty; a nonempty inventory missing it
@@ -116,11 +116,72 @@ managed import map. Browser Fetch and CORS behavior still apply. Importing it
116
116
  does not instantiate `AI`, read saved preferences, configure speech, or change
117
117
  the existing `arcane-os/ai` browser entry.
118
118
 
119
+ ## Evaluate caller-owned state with System One
120
+
121
+ Use the named `fetchSystemOneRequest` export for the provider's native
122
+ state-and-questions operation. The application supplies the model and complete
123
+ provider-compatible `state` and `questions`; the SDK selects no model or scoring
124
+ policy and assigns no meaning to questions, answer keys, or scales.
125
+
126
+ ```javascript
127
+ import {fetchSystemOneRequest} from 'arcane-os/ai/twin-cloud';
128
+
129
+ const response = await fetchSystemOneRequest({
130
+ twinKey: applicationRuntime.twinKey,
131
+ model: 'typesafe-jev-1.13.0',
132
+ state: applicationState,
133
+ questions: applicationQuestions,
134
+ signal: controller.signal
135
+ });
136
+ ```
137
+
138
+ `applicationRuntime`, `applicationState`, `applicationQuestions`, and
139
+ `controller` are supplied by the application. The example's JEV identifier is
140
+ an explicit caller selection, not an SDK default. For OSS chat-completion models
141
+ such as `openai-gpt-oss-120b` and `openai-gpt-oss-20b`, use `fetchRequest` with
142
+ caller-owned messages and structured-output settings instead. The application
143
+ chooses the operation matching its model's provider contract.
144
+
145
+ ```javascript
146
+ fetchSystemOneRequest({
147
+ twinKey,
148
+ model,
149
+ state,
150
+ questions,
151
+ signal,
152
+ id,
153
+ onRequest,
154
+ onResponse,
155
+ onRetry
156
+ })
157
+ // Promise<complete parsed provider JSON>
158
+ ```
159
+
160
+ The SDK posts exactly `{model,state,questions}` as JSON to
161
+ `https://inference.do-ai.run/v1/systemone`, using `twinKey` only as bearer
162
+ authentication. It adds no prompt, chat envelope, tool call, output limit, or
163
+ answer conversion. Caller content stays unchanged within ordinary JSON
164
+ transport encoding. The request is serialized before `onRequest`, so that
165
+ diagnostic callback cannot rewrite the dispatched payload. The entire parsed
166
+ provider JSON reaches `onResponse` and the returned promise without selecting
167
+ or transforming answers.
168
+
169
+ The explicit key/model requirements, optional `signal`, `id`, callbacks,
170
+ retry observer, complete errors, and lack of retained history follow the shared
171
+ request behavior above. `onRequest(request,id,metadata)` receives
172
+ `{operation:'systemone',transport:'http',destination:'https://inference.do-ai.run/v1/systemone'}`
173
+ as metadata. `onResponse(response,id,false)` receives the complete parsed
174
+ response. Both callbacks are awaited; failures propagate and cancellation is
175
+ checked again after they settle. The existing `fetchJSONResponse` owner
176
+ provides the same HTTP retries and cancellation for this operation; no separate
177
+ transport or retry loop is created.
178
+
119
179
  ### Shared low-level integration helpers
120
180
 
121
181
  The same module also exports the helpers used by browser `AI.js`. Ordinary
122
- callers use `fetchRequest`; these exports let SDK transport integration share
123
- the existing implementation rather than maintain another retry or body reader.
182
+ callers use `fetchRequest` or `fetchSystemOneRequest`; these exports let SDK
183
+ transport integration share the existing implementation rather than maintain
184
+ another retry or body reader.
124
185
 
125
186
  | Export | Contract |
126
187
  | --- | --- |
@@ -80,6 +80,7 @@ version; WebKitGTK availability must not be generalized to macOS.
80
80
  | Use local AI without coupling app code to Ollama HTTP | `Arcane.localAI`, `Arcane.ai`, or `/arcane/modules/Ollama.js` | Primarily **Native**; Android exposes a narrower admitted inference projection | Admission, errors, and managed-operation events are normalized. Direct Ollama response envelopes remain **Provider-native**. |
81
81
  | Use TWiN Cloud from the renderer profile | `/arcane/modules/AI.js` | **Cloud** from an allowed browser/native renderer | High-level chat behavior is normalized by the module. The TWiN access key authenticates remote LLM chat; raw provider diagnostics remain provider-specific. No automatic cloud fallback is inferred from local failure. |
82
82
  | Send one TWiN Cloud request with an explicit key and model | `fetchRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Keeps complete messages and returns the full parsed provider JSON. Rejected Fetch and HTTP 529 share three retries after 3000 ms; HTTP 429 overload retains unlimited three-second retries. Optional `onRetry` reports waiting/requesting phases without blocking recovery. Shared structured-output mapping and cancellation match browser TWiN transport. No browser profile, AI/user singleton, DBOPFS, or retained request history is created. |
83
+ | Evaluate caller-owned state and questions with TWiN System One | `fetchSystemOneRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Posts the explicit model and complete state/questions through the existing JSON transport. Returns the entire parsed provider result and shares its retry, diagnostic, and cancellation behavior. Model choice, question definitions, answer interpretation, and scoring policy stay with the caller; no conversation state is retained. |
83
84
  | Use speech through one application helper | `/arcane/modules/AI.js` and `Arcane.speech` | **Browser** or **Native** | Whisper and Kokoro keep their on-device paths; explicitly selected Web Speech STT and TWiN Cloud TTS declare their non-local behavior. The helper automatically cleans only the outbound synthesis-input copy and normalizes application-facing audio/text behavior while request/response plumbing differs below that boundary. |
84
85
  | Inspect or manage raw Ollama models | `Arcane.ollama` or `/arcane/modules/Ollama.js` | **Native** desktop Core for management; narrower Android inference only | Wrapper method names, errors, streaming correlation, and admission are Arcane-controlled. Direct Ollama success envelopes are intentionally provider-native. |
85
86
  | Use native terminal, installation, user, provisioning, or machine controls | matching `Arcane.*` namespace | **Native** and app/capability restricted | Calls and errors use the common bridge contract. Platform results can be host-specific and are marked in the method guide. |
@@ -6,7 +6,7 @@
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
7
7
  "moduleSystem": "ESM"
8
8
  },
9
- "memberCount": 219,
9
+ "memberCount": 220,
10
10
  "runtimeSubpathPatterns": {
11
11
  "arcane-os/modules/*": "./runtime/arcane/modules/*",
12
12
  "arcane-os/entities/*": "./runtime/arcane/entities/*"
@@ -3452,6 +3452,20 @@
3452
3452
  "protocol": "TWiN Cloud complete-response chat",
3453
3453
  "normalization": "Preserves complete messages and parsed provider JSON; maps explicit structured-output, tool, and reasoning options; shares overload-only HTTP 429 retry after 3000 ms and cancellation; retains no browser profile, storage, or request history"
3454
3454
  },
3455
+ {
3456
+ "id": "twin-cloud:fetchSystemOneRequest",
3457
+ "name": "fetchSystemOneRequest",
3458
+ "displayName": "fetchSystemOneRequest()",
3459
+ "kind": "function",
3460
+ "signature": "async fetchSystemOneRequest({twinKey,model,state,questions,signal=null,id=Date.now(),onRequest,onResponse,onRetry=null}={})",
3461
+ "entrypoints": ["arcane-os/ai/twin-cloud"],
3462
+ "primaryImport": "arcane-os/ai/twin-cloud",
3463
+ "group": "TWiN Cloud state-and-questions requests",
3464
+ "summary": "Sends caller-owned model, state, and questions to the native System One endpoint with explicit credentials.",
3465
+ "availability": "Node and Browser; remote HTTPS provider",
3466
+ "protocol": "TWiN Cloud native System One request",
3467
+ "normalization": "Preserves complete state and questions within JSON transport and returns the full parsed provider result; shares existing JSON transport retry, diagnostics, and cancellation; leaves model choice, scales, and answer interpretation with the caller and retains no history"
3468
+ },
3455
3469
  {
3456
3470
  "id": "twin-cloud:fetchHTTPResponse",
3457
3471
  "name": "fetchHTTPResponse",
@@ -1682,8 +1682,19 @@ On desktop native capture, an accessible editable BCP47 language control occupie
1682
1682
  the feedback area instead of idle transcription/voice readiness boilerplate.
1683
1683
  `recognitionLanguage` defaults to `navigator.language`, accepts an explicit
1684
1684
  language tag without restricting it to the suggested choices, and is passed to
1685
- the capture owner at start. The control is disabled during active capture so
1686
- changes affect the next capture. Native voice loading, errors, and speaking
1685
+ the capture owner at start. Opening or reopening the editable picker shows all
1686
+ suggestions regardless of its saved/current value. Filtering begins only after
1687
+ the user changes the input text; initialization, programmatic configuration,
1688
+ and choosing a suggestion leave the full list available on the next opening.
1689
+ Suggestions display their native language and region names alongside the exact
1690
+ BCP47 tags; they are suggestions, not an exhaustive provider support inventory.
1691
+ The selected or freely typed tag remains unchanged. Arrow keys navigate the
1692
+ suggestions, Enter chooses the active suggestion, and Escape or Tab closes the
1693
+ list. The picker is visible and enabled only for desktop live-transcription
1694
+ mode, including before recording begins. It is disabled during active capture
1695
+ so changes affect the next capture; leaving live-transcription mode closes and
1696
+ disables it. File/model transcription and conversation-language configuration
1697
+ remain separate. Native voice loading, errors, and speaking
1687
1698
  remain visible. Model-based transcription retains its existing status, and
1688
1699
  mobile speech retains its existing voice-output-only presentation.
1689
1700
 
@@ -123,6 +123,7 @@ browser map are cataloged separately in [Runtime modules](runtime-modules.md).
123
123
  | Member | Kind | Import | Group | Availability |
124
124
  | --- | --- | --- | --- | --- |
125
125
  | `fetchRequest()` | function | `arcane-os/ai/twin-cloud` | TWiN Cloud requests | Node and Browser; remote HTTPS provider |
126
+ | `fetchSystemOneRequest()` | function | `arcane-os/ai/twin-cloud` | TWiN Cloud state-and-questions requests | Node and Browser; remote HTTPS provider |
126
127
  | `fetchHTTPResponse()` | function | `arcane-os/ai/twin-cloud` | Shared AI transport integration | Node and Browser |
127
128
  | `fetchJSONResponse()` | function | `arcane-os/ai/twin-cloud` | Shared AI transport integration | Node and Browser |
128
129
  | `structuredOutputFormat()` | function | `arcane-os/ai/twin-cloud` | Shared AI transport integration | Node and Browser |
@@ -7953,6 +7954,59 @@ console.log(response);
7953
7954
  `server-config.json` is an application-owned, ignored configuration file;
7954
7955
  never commit its key or print it in diagnostics. The SDK does not read it.
7955
7956
 
7957
+ ## fetchSystemOneRequest()
7958
+
7959
+ ### Overview
7960
+
7961
+ Sends one native TWiN System One request with caller-owned credentials, model,
7962
+ state, and questions. Question definitions, scales, model selection, and answer
7963
+ interpretation remain application responsibilities.
7964
+
7965
+ ### Signature and result
7966
+
7967
+ ```text
7968
+ async fetchSystemOneRequest({twinKey,model,state,questions,signal=null,id=Date.now(),onRequest,onResponse,onRetry=null}={})
7969
+ ```
7970
+
7971
+ Import the named function from `arcane-os/ai/twin-cloud`. It posts
7972
+ `{model,state,questions}` to `https://inference.do-ai.run/v1/systemone` and
7973
+ returns the entire parsed provider JSON. It adds no chat messages, tools,
7974
+ scoring rules, or output limit. Payload content is preserved within ordinary
7975
+ JSON transport encoding; serialization occurs before the diagnostic callback.
7976
+
7977
+ `onRequest(request,id,metadata)` receives the complete payload before dispatch,
7978
+ with `{operation:'systemone',transport:'http',destination:'https://inference.do-ai.run/v1/systemone'}`
7979
+ as metadata. `onResponse(response,id,false)` receives the entire parsed result
7980
+ before return. Both callbacks are awaited and failures propagate. The key is
7981
+ used only for transport authentication, outside the diagnostic payloads.
7982
+
7983
+ ### Availability and normalization
7984
+
7985
+ **Node and Browser; Cloud transport.** Uses the existing `fetchJSONResponse`
7986
+ owner for complete errors, retries, `onRetry`, and `signal` cancellation, with
7987
+ no browser singleton, profile, storage, or retained history. A missing key uses
7988
+ `AI_PROVIDER_NOT_CONFIGURED`; a missing explicit model throws `TypeError`.
7989
+ Cancellation prevents successful return and uses `ARCANE_AI_REQUEST_ABORTED`,
7990
+ including checks after callback settlement. Import starts no request.
7991
+
7992
+ ### Example
7993
+
7994
+ ```javascript
7995
+ import {fetchSystemOneRequest} from 'arcane-os/ai/twin-cloud';
7996
+
7997
+ const response = await fetchSystemOneRequest({
7998
+ twinKey: applicationRuntime.twinKey,
7999
+ model: 'typesafe-jev-1.13.0',
8000
+ state: applicationState,
8001
+ questions: applicationQuestions
8002
+ });
8003
+ ```
8004
+
8005
+ The runtime configuration, state, and questions in this example belong to the
8006
+ application. The JEV identifier is an explicit caller choice, not a default.
8007
+ See [TWiN Cloud](ai/twin-cloud.md#evaluate-caller-owned-state-with-system-one)
8008
+ for the complete transport and callback contract.
8009
+
7956
8010
  ## fetchHTTPResponse()
7957
8011
 
7958
8012
  ### Overview
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -99,10 +99,17 @@
99
99
  display: none;
100
100
  }
101
101
 
102
+ .recognition_language_picker {
103
+ position: relative;
104
+ display: flex;
105
+ width: 18rem;
106
+ max-width: 100%;
107
+ }
108
+
102
109
  #recognitionLanguage {
103
110
  box-sizing: border-box;
104
- width: 10rem;
105
- max-width: 100%;
111
+ width: 100%;
112
+ min-width: 0;
106
113
  min-height: 2.25rem;
107
114
  padding: .35rem .5rem;
108
115
  border: 1px solid var(--arcane-border, var(--border-color));
@@ -112,6 +119,52 @@
112
119
  font: inherit;
113
120
  }
114
121
 
122
+ #recognitionLanguageToggle {
123
+ min-height: 2.25rem;
124
+ padding: .35rem .6rem;
125
+ background: var(--arcane-surface, var(--background-color));
126
+ color: var(--arcane-text, var(--text-color));
127
+ }
128
+
129
+ #recognitionLanguages {
130
+ position: absolute;
131
+ z-index: 1;
132
+ inset-inline: 0;
133
+ bottom: 100%;
134
+ max-height: 18rem;
135
+ overflow: auto;
136
+ border: 1px solid var(--arcane-border, var(--border-color));
137
+ border-radius: var(--arcane-radius-small, .4rem);
138
+ background: var(--arcane-surface, var(--background-color));
139
+ box-shadow: var(--arcane-shadow-small, 0 .25rem .8rem var(--shadow-color));
140
+ }
141
+
142
+ #recognitionLanguages button {
143
+ display: block;
144
+ width: 100%;
145
+ border: 0;
146
+ border-radius: 0;
147
+ background: var(--arcane-surface, var(--background-color));
148
+ color: var(--arcane-text, var(--text-color));
149
+ font-weight: normal;
150
+ text-align: start;
151
+ overflow-wrap: anywhere;
152
+ touch-action: pan-y;
153
+ }
154
+
155
+ #recognitionLanguages button[hidden] {
156
+ display: none;
157
+ }
158
+
159
+ #recognitionLanguages button[data-active="true"] {
160
+ outline: .2rem solid var(--arcane-focus, var(--focus-color));
161
+ outline-offset: -.2rem;
162
+ }
163
+
164
+ #recognitionLanguages button[aria-selected="true"] {
165
+ font-weight: 700;
166
+ }
167
+
115
168
  .speech_status {
116
169
  margin: 0;
117
170
  color: var(--arcane-muted, var(--text-color));
@@ -189,25 +242,28 @@
189
242
  <button id="sttActivationButton" type="button" hidden disabled>Start transcription</button>
190
243
  <button id="stopButton" type="button" class="hidden" disabled>Send</button>
191
244
  <div class="speech_feedback">
192
- <label id="recognitionLanguageControl" class="recognition_language" hidden>
193
- Recognition language
194
- <input id="recognitionLanguage" type="text" list="recognitionLanguages" placeholder="Browser default" title="Recognition language tag, such as en-US or es-MX" autocomplete="off" spellcheck="false">
195
- <datalist id="recognitionLanguages">
196
- <option value="en-US">English (United States)</option>
197
- <option value="en-GB">English (United Kingdom)</option>
198
- <option value="es-MX">Spanish (Mexico)</option>
199
- <option value="es-ES">Spanish (Spain)</option>
200
- <option value="fr-FR">French</option>
201
- <option value="de-DE">German</option>
202
- <option value="it-IT">Italian</option>
203
- <option value="pt-BR">Portuguese (Brazil)</option>
204
- <option value="ja-JP">Japanese</option>
205
- <option value="ko-KR">Korean</option>
206
- <option value="zh-CN">Chinese (Mandarin, mainland China)</option>
207
- <option value="hi-IN">Hindi</option>
208
- <option value="ar-SA">Arabic (Saudi Arabia)</option>
209
- </datalist>
210
- </label>
245
+ <div id="recognitionLanguageControl" class="recognition_language" hidden>
246
+ <label for="recognitionLanguage">Recognition language</label>
247
+ <div class="recognition_language_picker">
248
+ <input id="recognitionLanguage" type="text" role="combobox" aria-controls="recognitionLanguages" aria-expanded="false" aria-autocomplete="list" aria-haspopup="listbox" placeholder="Browser default" title="Recognition language tag; type to filter suggestions or enter another BCP47 tag" autocomplete="off" spellcheck="false">
249
+ <button id="recognitionLanguageToggle" type="button" tabindex="-1" aria-label="Show all suggested recognition languages" aria-controls="recognitionLanguages" aria-expanded="false">▾</button>
250
+ <div id="recognitionLanguages" role="listbox" aria-label="Suggested recognition languages" hidden>
251
+ <button id="recognitionLanguage-en-US" type="button" role="option" data-language="en-US" tabindex="-1" lang="en-US" dir="auto">English (United States) — en-US</button>
252
+ <button id="recognitionLanguage-en-GB" type="button" role="option" data-language="en-GB" tabindex="-1" lang="en-GB" dir="auto">English (United Kingdom) — en-GB</button>
253
+ <button id="recognitionLanguage-es-MX" type="button" role="option" data-language="es-MX" tabindex="-1" lang="es-MX" dir="auto">Español (México) — es-MX</button>
254
+ <button id="recognitionLanguage-es-ES" type="button" role="option" data-language="es-ES" tabindex="-1" lang="es-ES" dir="auto">Español (España) — es-ES</button>
255
+ <button id="recognitionLanguage-fr-FR" type="button" role="option" data-language="fr-FR" tabindex="-1" lang="fr-FR" dir="auto">Français (France) — fr-FR</button>
256
+ <button id="recognitionLanguage-de-DE" type="button" role="option" data-language="de-DE" tabindex="-1" lang="de-DE" dir="auto">Deutsch (Deutschland) — de-DE</button>
257
+ <button id="recognitionLanguage-it-IT" type="button" role="option" data-language="it-IT" tabindex="-1" lang="it-IT" dir="auto">Italiano (Italia) — it-IT</button>
258
+ <button id="recognitionLanguage-pt-BR" type="button" role="option" data-language="pt-BR" tabindex="-1" lang="pt-BR" dir="auto">Português (Brasil) — pt-BR</button>
259
+ <button id="recognitionLanguage-ja-JP" type="button" role="option" data-language="ja-JP" tabindex="-1" lang="ja-JP" dir="auto">日本語(日本) — ja-JP</button>
260
+ <button id="recognitionLanguage-ko-KR" type="button" role="option" data-language="ko-KR" tabindex="-1" lang="ko-KR" dir="auto">한국어 (대한민국) — ko-KR</button>
261
+ <button id="recognitionLanguage-zh-CN" type="button" role="option" data-language="zh-CN" tabindex="-1" lang="zh-CN" dir="auto">中文(中国大陆,普通话) — zh-CN</button>
262
+ <button id="recognitionLanguage-hi-IN" type="button" role="option" data-language="hi-IN" tabindex="-1" lang="hi-IN" dir="auto">हिन्दी (भारत) — hi-IN</button>
263
+ <button id="recognitionLanguage-ar-SA" type="button" role="option" data-language="ar-SA" tabindex="-1" lang="ar-SA" dir="auto">العربية (المملكة العربية السعودية) — ar-SA</button>
264
+ </div>
265
+ </div>
266
+ </div>
211
267
  <p id="speechStatus" class="speech_status" role="status" aria-live="polite"></p>
212
268
  <progress
213
269
  id="sttActivationProgress"
@@ -281,6 +337,11 @@
281
337
  const speechStatus = shadowRoot.querySelector('#speechStatus');
282
338
  const recognitionLanguageControl = shadowRoot.querySelector('#recognitionLanguageControl');
283
339
  const recognitionLanguageInput = shadowRoot.querySelector('#recognitionLanguage');
340
+ const recognitionLanguageToggle = shadowRoot.querySelector('#recognitionLanguageToggle');
341
+ const recognitionLanguages = shadowRoot.querySelector('#recognitionLanguages');
342
+ const recognitionLanguageOptions = Array.from(
343
+ recognitionLanguages.querySelectorAll('[data-language]')
344
+ );
284
345
  const silenceDuration = 0;
285
346
  const runtimeStateAbortController = new AbortController();
286
347
 
@@ -319,6 +380,9 @@
319
380
  set: setRecognitionLanguage
320
381
  });
321
382
  recognitionLanguageInput.value = recognitionLanguage;
383
+ let recognitionLanguageInputValue = recognitionLanguage;
384
+ let activeRecognitionLanguageOption = null;
385
+ renderRecognitionLanguageOptions();
322
386
 
323
387
  function nextSpeechOperationId(kind) {
324
388
  eventOperationSequence += 1;
@@ -459,6 +523,16 @@
459
523
  stopButton.addEventListener('click', stop, lifecycleListenerOptions);
460
524
  muteButton.addEventListener('click', muteToggle, lifecycleListenerOptions);
461
525
  recognitionLanguageInput.addEventListener('input', updateRecognitionLanguage, lifecycleListenerOptions);
526
+ recognitionLanguageInput.addEventListener('focus', openRecognitionLanguages, lifecycleListenerOptions);
527
+ recognitionLanguageInput.addEventListener('click', openRecognitionLanguages, lifecycleListenerOptions);
528
+ recognitionLanguageInput.addEventListener('blur', closeRecognitionLanguages, lifecycleListenerOptions);
529
+ recognitionLanguageInput.addEventListener('keydown', handleRecognitionLanguageKeyDown, lifecycleListenerOptions);
530
+ recognitionLanguageToggle.addEventListener('pointerdown', retainRecognitionLanguageFocus, lifecycleListenerOptions);
531
+ recognitionLanguageToggle.addEventListener('click', toggleRecognitionLanguages, lifecycleListenerOptions);
532
+ for (const option of recognitionLanguageOptions) {
533
+ option.addEventListener('pointerdown', retainRecognitionLanguageFocus, lifecycleListenerOptions);
534
+ option.addEventListener('click', selectRecognitionLanguage, lifecycleListenerOptions);
535
+ }
462
536
  window.addEventListener('ai-ready', synchronizeAIReadiness, lifecycleListenerOptions);
463
537
  window.addEventListener(
464
538
  'pagehide',
@@ -556,10 +630,121 @@
556
630
  }
557
631
  recognitionLanguage = language;
558
632
  recognitionLanguageInput.value = language;
633
+ recognitionLanguageInputValue = language;
634
+ renderRecognitionLanguageOptions();
559
635
  }
560
636
 
561
637
  function updateRecognitionLanguage() {
562
- host.recognitionLanguage = recognitionLanguageInput.value;
638
+ if (recognitionLanguageInput.disabled
639
+ || recognitionLanguageInput.value === recognitionLanguageInputValue) {
640
+ return;
641
+ }
642
+ recognitionLanguage = recognitionLanguageInput.value;
643
+ recognitionLanguageInputValue = recognitionLanguage;
644
+ openRecognitionLanguages();
645
+ renderRecognitionLanguageOptions(recognitionLanguage);
646
+ }
647
+
648
+ function renderRecognitionLanguageOptions(search = '') {
649
+ const query = search.toLocaleLowerCase();
650
+ activeRecognitionLanguageOption = null;
651
+ recognitionLanguageInput.removeAttribute('aria-activedescendant');
652
+ for (const option of recognitionLanguageOptions) {
653
+ option.hidden = !option.textContent.toLocaleLowerCase().includes(query);
654
+ option.dataset.active = 'false';
655
+ option.setAttribute('aria-selected', String(option.dataset.language === recognitionLanguage));
656
+ }
657
+ }
658
+
659
+ function openRecognitionLanguages() {
660
+ if (recognitionLanguageControl.hidden || recognitionLanguageInput.disabled
661
+ || !recognitionLanguages.hidden) {
662
+ return;
663
+ }
664
+ renderRecognitionLanguageOptions();
665
+ recognitionLanguages.hidden = false;
666
+ recognitionLanguageInput.setAttribute('aria-expanded', 'true');
667
+ recognitionLanguageToggle.setAttribute('aria-expanded', 'true');
668
+ }
669
+
670
+ function closeRecognitionLanguages() {
671
+ recognitionLanguages.hidden = true;
672
+ recognitionLanguageInput.setAttribute('aria-expanded', 'false');
673
+ recognitionLanguageToggle.setAttribute('aria-expanded', 'false');
674
+ recognitionLanguageInput.removeAttribute('aria-activedescendant');
675
+ activeRecognitionLanguageOption = null;
676
+ }
677
+
678
+ function retainRecognitionLanguageFocus(event) {
679
+ event.preventDefault();
680
+ }
681
+
682
+ function toggleRecognitionLanguages() {
683
+ if (recognitionLanguageInput.disabled) {
684
+ return;
685
+ }
686
+ const wasOpen = !recognitionLanguages.hidden;
687
+ recognitionLanguageInput.focus();
688
+ if (wasOpen) {
689
+ closeRecognitionLanguages();
690
+ } else {
691
+ openRecognitionLanguages();
692
+ }
693
+ }
694
+
695
+ function selectRecognitionLanguage(event) {
696
+ if (recognitionLanguageInput.disabled) {
697
+ return;
698
+ }
699
+ host.recognitionLanguage = event.currentTarget.dataset.language;
700
+ recognitionLanguageInput.focus();
701
+ closeRecognitionLanguages();
702
+ }
703
+
704
+ function handleRecognitionLanguageKeyDown(event) {
705
+ if (recognitionLanguageInput.disabled || event.isComposing) {
706
+ return;
707
+ }
708
+ if (event.key === 'Escape' || event.key === 'Tab') {
709
+ if (event.key === 'Escape' && !recognitionLanguages.hidden) {
710
+ event.preventDefault();
711
+ }
712
+ closeRecognitionLanguages();
713
+ return;
714
+ }
715
+ if (event.key === 'Enter' && !recognitionLanguages.hidden) {
716
+ event.preventDefault();
717
+ if (activeRecognitionLanguageOption) {
718
+ host.recognitionLanguage = activeRecognitionLanguageOption.dataset.language;
719
+ }
720
+ closeRecognitionLanguages();
721
+ return;
722
+ }
723
+ if (!['ArrowDown', 'ArrowUp'].includes(event.key)) {
724
+ return;
725
+ }
726
+ event.preventDefault();
727
+ openRecognitionLanguages();
728
+ const visibleOptions = recognitionLanguageOptions.filter(
729
+ function visibleRecognitionLanguage(option) {
730
+ return !option.hidden;
731
+ }
732
+ );
733
+ if (!visibleOptions.length) {
734
+ return;
735
+ }
736
+ const previousIndex = visibleOptions.indexOf(activeRecognitionLanguageOption);
737
+ const nextIndex = previousIndex < 0
738
+ ? (event.key === 'ArrowDown' ? 0 : visibleOptions.length - 1)
739
+ : (previousIndex + (event.key === 'ArrowDown' ? 1 : -1) + visibleOptions.length)
740
+ % visibleOptions.length;
741
+ if (activeRecognitionLanguageOption) {
742
+ activeRecognitionLanguageOption.dataset.active = 'false';
743
+ }
744
+ activeRecognitionLanguageOption = visibleOptions[nextIndex];
745
+ activeRecognitionLanguageOption.dataset.active = 'true';
746
+ recognitionLanguageInput.setAttribute('aria-activedescendant', activeRecognitionLanguageOption.id);
747
+ activeRecognitionLanguageOption.scrollIntoView({block: 'nearest'});
563
748
  }
564
749
 
565
750
  function updateMicrophoneAvailability(available) {
@@ -701,9 +886,14 @@
701
886
  recognitionLanguageControl.hidden = !transcriptionEnabled
702
887
  || !providerRuntime.supportsTranscriptionCapture();
703
888
  recognitionLanguageInput.disabled = destroyed
889
+ || recognitionLanguageControl.hidden
704
890
  || recordingRequested
705
891
  || Boolean(nativeCaptureSession)
706
892
  || transcriptionActive;
893
+ recognitionLanguageToggle.disabled = recognitionLanguageInput.disabled;
894
+ if (recognitionLanguageInput.disabled) {
895
+ closeRecognitionLanguages();
896
+ }
707
897
  if (!transcriptionEnabled) {
708
898
  recordButton.hidden = true;
709
899
  recordButton.disabled = true;