@viamrobotics/test-widgets 0.1.11 → 0.2.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.
Files changed (28) hide show
  1. package/dist/builtin.js +3 -1
  2. package/dist/client-map.d.ts +5 -2
  3. package/dist/client-map.js +7 -1
  4. package/dist/components/error.svelte +40 -9
  5. package/dist/components/index.d.ts +2 -0
  6. package/dist/components/index.js +2 -0
  7. package/dist/components/queries.svelte +8 -2
  8. package/dist/components/refetch-interval-store.svelte.d.ts +1 -1
  9. package/dist/components/section-group.svelte +1 -1
  10. package/dist/components/widgets/audio-input/audio-input.svelte +153 -0
  11. package/dist/components/widgets/audio-input/audio-input.svelte.d.ts +7 -0
  12. package/dist/components/widgets/audio-input/create-audio-capturer.svelte.d.ts +13 -0
  13. package/dist/components/widgets/audio-input/create-audio-capturer.svelte.js +85 -0
  14. package/dist/components/widgets/audio-input/properties.svelte +26 -0
  15. package/dist/components/widgets/audio-input/properties.svelte.d.ts +8 -0
  16. package/dist/components/widgets/audio-output/audio-output.svelte +200 -0
  17. package/dist/components/widgets/audio-output/audio-output.svelte.d.ts +7 -0
  18. package/dist/components/widgets/audio-output/codec.d.ts +2 -0
  19. package/dist/components/widgets/audio-output/codec.js +16 -0
  20. package/dist/components/widgets/audio-output/create-audio-player.svelte.d.ts +10 -0
  21. package/dist/components/widgets/audio-output/create-audio-player.svelte.js +28 -0
  22. package/dist/components/widgets/audio-output/properties.svelte +26 -0
  23. package/dist/components/widgets/audio-output/properties.svelte.d.ts +8 -0
  24. package/dist/components/widgets/do-command/create-do-command-client.svelte.d.ts +7 -0
  25. package/dist/components/widgets/do-command/create-do-command-client.svelte.js +26 -0
  26. package/dist/components/widgets/do-command/do-command.svelte +21 -22
  27. package/dist/components/widgets/do-command/do-command.svelte.d.ts +1 -1
  28. package/package.json +5 -7
package/dist/builtin.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ArmWidget, BaseWidget, BoardWidget, ButtonWidget, CameraWidget, DiscoveryWidget, EncoderWidget, GantryWidget, GripperWidget, InputControllerWidget, MLModelServiceWidget, MotorWidget, MovementSensorWidget, NavigationServiceWidget, PowerSensorWidget, SensorWidget, ServoWidget, SlamWidget, SwitchWidget, VisionServiceWidget, } from './';
1
+ import { ArmWidget, AudioInputWidget, AudioOutputWidget, BaseWidget, BoardWidget, ButtonWidget, CameraWidget, DiscoveryWidget, EncoderWidget, GantryWidget, GripperWidget, InputControllerWidget, MLModelServiceWidget, MotorWidget, MovementSensorWidget, NavigationServiceWidget, PowerSensorWidget, SensorWidget, ServoWidget, SlamWidget, SwitchWidget, VisionServiceWidget, } from './';
2
2
  import { clientMap } from "./client-map.js";
3
3
  import { getResourceAPI } from "./resource.js";
4
4
  export { clientForBuiltinResource } from "./client-map.js";
@@ -9,6 +9,8 @@ const resourceMap =
9
9
  // list created via `cat rdkbuiltins/viam-server-stable.json | rg "api" | sort | uniq`
10
10
  {
11
11
  'rdk:component:arm': [clientMap['rdk:component:arm'], ArmWidget, true],
12
+ 'rdk:component:audio_in': [clientMap['rdk:component:audio_in'], AudioInputWidget, true],
13
+ 'rdk:component:audio_out': [clientMap['rdk:component:audio_out'], AudioOutputWidget, true],
12
14
  'rdk:component:base': [clientMap['rdk:component:base'], BaseWidget, true],
13
15
  'rdk:component:board': [clientMap['rdk:component:board'], BoardWidget, true],
14
16
  'rdk:component:button': [clientMap['rdk:component:button'], ButtonWidget, true],
@@ -1,6 +1,8 @@
1
- import { ArmClient, BaseClient, BoardClient, ButtonClient, CameraClient, DataManagerClient, DiscoveryClient, EncoderClient, GantryClient, GenericComponentClient, GenericServiceClient, GripperClient, InputControllerClient, MLModelClient, MotionClient, MotorClient, MovementSensorClient, NavigationClient, PoseTrackerClient, PowerSensorClient, type ResourceName, SensorClient, ServoClient, SlamClient, SwitchClient, VideoClient, VisionClient, WorldStateStoreClient } from '@viamrobotics/sdk';
1
+ import { ArmClient, AudioInClient, AudioOutClient, BaseClient, BoardClient, ButtonClient, CameraClient, DataManagerClient, DiscoveryClient, EncoderClient, GantryClient, GenericComponentClient, GenericServiceClient, GripperClient, InputControllerClient, MLModelClient, MotionClient, MotorClient, MovementSensorClient, NavigationClient, PoseTrackerClient, PowerSensorClient, type ResourceName, SensorClient, ServoClient, SlamClient, SwitchClient, VideoClient, VisionClient, WorldStateStoreClient } from '@viamrobotics/sdk';
2
2
  export declare const clientMap: {
3
3
  readonly 'rdk:component:arm': typeof ArmClient;
4
+ readonly 'rdk:component:audio_in': typeof AudioInClient;
5
+ readonly 'rdk:component:audio_out': typeof AudioOutClient;
4
6
  readonly 'rdk:component:base': typeof BaseClient;
5
7
  readonly 'rdk:component:board': typeof BoardClient;
6
8
  readonly 'rdk:component:button': typeof ButtonClient;
@@ -28,4 +30,5 @@ export declare const clientMap: {
28
30
  readonly 'rdk:service:world_state_store': typeof WorldStateStoreClient;
29
31
  readonly 'rdk:service:video': typeof VideoClient;
30
32
  };
31
- export declare const clientForBuiltinResource: (resource: ResourceName) => typeof ArmClient | typeof BaseClient | typeof GantryClient | typeof GripperClient | typeof MotorClient | typeof ServoClient | typeof BoardClient | typeof ButtonClient | typeof CameraClient | typeof DiscoveryClient | typeof EncoderClient | typeof GenericComponentClient | typeof InputControllerClient | typeof MovementSensorClient | typeof PoseTrackerClient | typeof PowerSensorClient | typeof SensorClient | typeof SwitchClient | typeof DataManagerClient | typeof GenericServiceClient | typeof MLModelClient | typeof MotionClient | typeof NavigationClient | typeof SlamClient | typeof VisionClient | typeof WorldStateStoreClient | typeof VideoClient | undefined;
33
+ export declare const clientForBuiltinResource: (resource: ResourceName) => typeof ArmClient | typeof BaseClient | typeof GantryClient | typeof GripperClient | typeof MotorClient | typeof ServoClient | typeof AudioInClient | typeof AudioOutClient | typeof BoardClient | typeof ButtonClient | typeof CameraClient | typeof DiscoveryClient | typeof EncoderClient | typeof GenericComponentClient | typeof InputControllerClient | typeof MovementSensorClient | typeof PoseTrackerClient | typeof PowerSensorClient | typeof SensorClient | typeof SwitchClient | typeof DataManagerClient | typeof GenericServiceClient | typeof MLModelClient | typeof MotionClient | typeof NavigationClient | typeof SlamClient | typeof VisionClient | typeof WorldStateStoreClient | typeof VideoClient | undefined;
34
+ export declare const supportsDoCommand: (resource: ResourceName) => boolean;
@@ -1,7 +1,9 @@
1
- import { ArmClient, BaseClient, BoardClient, ButtonClient, CameraClient, DataManagerClient, DiscoveryClient, EncoderClient, GantryClient, GenericComponentClient, GenericServiceClient, GripperClient, InputControllerClient, MLModelClient, MotionClient, MotorClient, MovementSensorClient, NavigationClient, PoseTrackerClient, PowerSensorClient, SensorClient, ServoClient, SlamClient, SwitchClient, VideoClient, VisionClient, WorldStateStoreClient, } from '@viamrobotics/sdk';
1
+ import { ArmClient, AudioInClient, AudioOutClient, BaseClient, BoardClient, ButtonClient, CameraClient, DataManagerClient, DiscoveryClient, EncoderClient, GantryClient, GenericComponentClient, GenericServiceClient, GripperClient, InputControllerClient, MLModelClient, MotionClient, MotorClient, MovementSensorClient, NavigationClient, PoseTrackerClient, PowerSensorClient, SensorClient, ServoClient, SlamClient, SwitchClient, VideoClient, VisionClient, WorldStateStoreClient, } from '@viamrobotics/sdk';
2
2
  import { getResourceAPI } from "./resource.js";
3
3
  export const clientMap = {
4
4
  'rdk:component:arm': ArmClient,
5
+ 'rdk:component:audio_in': AudioInClient,
6
+ 'rdk:component:audio_out': AudioOutClient,
5
7
  'rdk:component:base': BaseClient,
6
8
  'rdk:component:board': BoardClient,
7
9
  'rdk:component:button': ButtonClient,
@@ -33,3 +35,7 @@ export const clientForBuiltinResource = (resource) => {
33
35
  const resAPI = getResourceAPI(resource);
34
36
  return resAPI in clientMap ? clientMap[resAPI] : undefined;
35
37
  };
38
+ export const supportsDoCommand = (resource) => {
39
+ const client = clientForBuiltinResource(resource);
40
+ return client !== undefined && client !== MLModelClient;
41
+ };
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { IconButton, Tooltip } from '@viamrobotics/prime-core'
2
3
  import { twMerge } from 'tailwind-merge'
3
4
 
4
5
  interface Props {
@@ -11,15 +12,45 @@
11
12
 
12
13
  const { lastError, id = `error_${defaultId}`, class: className = '' }: Props = $props()
13
14
 
14
- const errorName = $derived(lastError?.name)
15
- const errorMessage = $derived(lastError?.message)
15
+ const errorText = $derived(
16
+ lastError?.name && lastError.message ? `${lastError.name}: ${lastError.message}` : ''
17
+ )
18
+
19
+ let showCopySuccess = $state(false)
20
+ let copySuccessTimeoutId: ReturnType<typeof setTimeout> | undefined
21
+
22
+ const copyErrorToClipboard = async () => {
23
+ try {
24
+ await globalThis.navigator.clipboard.writeText(errorText)
25
+ showCopySuccess = true
26
+ clearTimeout(copySuccessTimeoutId)
27
+ copySuccessTimeoutId = setTimeout(() => {
28
+ showCopySuccess = false
29
+ }, 750)
30
+ } catch (error) {
31
+ console.error('Failed to copy error to clipboard', error)
32
+ }
33
+ }
34
+
35
+ $effect(() => () => clearTimeout(copySuccessTimeoutId))
16
36
  </script>
17
37
 
18
- {#if errorName && errorMessage}
19
- <p
20
- {id}
21
- class={twMerge('font-roboto-mono text-danger-dark text-xs', className)}
22
- >
23
- {`${errorName}: ${errorMessage}`}
24
- </p>
38
+ {#if errorText}
39
+ <div class="flex items-center justify-between gap-1">
40
+ <p
41
+ {id}
42
+ class={twMerge('font-roboto-mono text-danger-dark text-xs', className)}
43
+ >
44
+ {errorText}
45
+ </p>
46
+ <Tooltip let:tooltipID>
47
+ <IconButton
48
+ aria-describedby={tooltipID}
49
+ icon={showCopySuccess ? 'check' : 'content-copy'}
50
+ label="Copy error"
51
+ on:click={copyErrorToClipboard}
52
+ />
53
+ <div slot="description">Copy error</div>
54
+ </Tooltip>
55
+ </div>
25
56
  {/if}
@@ -3,6 +3,8 @@ export { default as ArmWidget } from './widgets/arm/arm.svelte';
3
3
  export { default as ArmMoveToJointPositionsWidget } from './widgets/arm/move-to-joint-positions-widget.svelte';
4
4
  export { default as ArmMoveToPositionWidget } from './widgets/arm/move-to-position-widget.svelte';
5
5
  export { default as ArmQuickMoveWidget } from './widgets/arm/quick-move-widget.svelte';
6
+ export { default as AudioInputWidget } from './widgets/audio-input/audio-input.svelte';
7
+ export { default as AudioOutputWidget } from './widgets/audio-output/audio-output.svelte';
6
8
  export { default as BaseWidget } from './widgets/base/base.svelte';
7
9
  export { default as BaseMoveStraightWidget } from './widgets/base/move-straight-widget.svelte';
8
10
  export { default as BaseQuickMoveWidget } from './widgets/base/quick-move-widget.svelte';
@@ -3,6 +3,8 @@ export { default as ArmWidget } from './widgets/arm/arm.svelte';
3
3
  export { default as ArmMoveToJointPositionsWidget } from './widgets/arm/move-to-joint-positions-widget.svelte';
4
4
  export { default as ArmMoveToPositionWidget } from './widgets/arm/move-to-position-widget.svelte';
5
5
  export { default as ArmQuickMoveWidget } from './widgets/arm/quick-move-widget.svelte';
6
+ export { default as AudioInputWidget } from './widgets/audio-input/audio-input.svelte';
7
+ export { default as AudioOutputWidget } from './widgets/audio-output/audio-output.svelte';
6
8
  export { default as BaseWidget } from './widgets/base/base.svelte';
7
9
  export { default as BaseMoveStraightWidget } from './widgets/base/move-straight-widget.svelte';
8
10
  export { default as BaseQuickMoveWidget } from './widgets/base/quick-move-widget.svelte';
@@ -29,11 +29,17 @@
29
29
  let errors = $state.raw<Error[]>([])
30
30
  let data = $state.raw<unknown[]>([])
31
31
 
32
+ const serializeErrors = (errs: Error[]) =>
33
+ errs.map((error) => `${error.name}\0${error.message}`).join('\0\0')
34
+
32
35
  // Errors are null during loading, so keep the latest errors during polling.
36
+ // Only update errors if the content has changed to avoid re-rendering identical errors.
33
37
  $effect.pre(() => {
34
38
  const nextErrors = queries.map((query) => query.error).filter((error) => error !== null)
35
39
  if (nextErrors.length > 0) {
36
- errors = nextErrors
40
+ if (serializeErrors(nextErrors) !== serializeErrors(errors)) {
41
+ errors = nextErrors
42
+ }
37
43
  } else if (queries.every((query) => query.isSuccess)) {
38
44
  errors = []
39
45
  }
@@ -55,7 +61,7 @@
55
61
  {contentRect}
56
62
  cx={contentCx}
57
63
  >
58
- {#each errors as error (error)}
64
+ {#each errors as error (`${error.name}\0${error.message}`)}
59
65
  <ErrorDisplay lastError={error} />
60
66
  {/each}
61
67
  </ContentRect>
@@ -14,5 +14,5 @@ export declare const RefetchIntervals: {
14
14
  };
15
15
  export type RefetchRate = ValueOf<typeof RefetchRates>;
16
16
  export type RefetchInterval = ValueOf<typeof RefetchIntervals>;
17
- export declare const RefetchRateToInterval: ["Live" | "Refresh every second" | "Refresh every 5 seconds" | "Manual refresh", false | 1000 | 33.3333 | 5000][];
17
+ export declare const RefetchRateToInterval: ["Live" | "Refresh every second" | "Refresh every 5 seconds" | "Manual refresh", false | 33.3333 | 1000 | 5000][];
18
18
  export declare const createRefetchIntervalStore: (partID: () => string, resourceName: () => string, scope: string, defaultInterval?: RefetchInterval) => PersistedState<RefetchInterval>;
@@ -32,7 +32,7 @@ A group of sections in a card. For example, "test" or "do command"
32
32
  >
33
33
  <header class={['border-gray-3 bg-light hover:bg-medium h-7', !isCollapsed && 'border-b']}>
34
34
  <button
35
- class="group flex h-full w-full flex-row items-center gap-2 px-[11px]"
35
+ class="group flex h-full w-full flex-row items-center gap-2 px-2.75"
36
36
  aria-controls={collapseID}
37
37
  aria-expanded={!isCollapsed}
38
38
  aria-label={`${isCollapsed ? 'expand' : 'collapse'} ${title}`}
@@ -0,0 +1,153 @@
1
+ <script lang="ts">
2
+ import { Button, Label, NumericInput, Select } from '@viamrobotics/prime-core'
3
+ import { AudioInClient } from '@viamrobotics/sdk'
4
+ import { createResourceClient, createResourceQuery } from '@viamrobotics/svelte-sdk'
5
+
6
+ import ApiSection from '../../api-section.svelte'
7
+ import ConnectionStatus from '../../connection-status.svelte'
8
+ import MutationSection from '../../mutation-section.svelte'
9
+ import Query from '../../query.svelte'
10
+ import RefetchController from '../../refetch-controller.svelte'
11
+ import { createRefetchIntervalStore } from '../../refetch-interval-store.svelte'
12
+ import { numberValueFromEvent } from '../../../event-handlers'
13
+
14
+ import { createAudioCapturer } from './create-audio-capturer.svelte.js'
15
+ import Properties from './properties.svelte'
16
+
17
+ interface Props {
18
+ partID: string
19
+ resourceName: string
20
+ }
21
+
22
+ const { partID, resourceName }: Props = $props()
23
+
24
+ const refetchInterval = createRefetchIntervalStore(
25
+ () => partID,
26
+ () => resourceName,
27
+ 'audio-input'
28
+ )
29
+
30
+ const client = createResourceClient(
31
+ AudioInClient,
32
+ () => partID,
33
+ () => resourceName
34
+ )
35
+
36
+ const propertiesQuery = createResourceQuery(client, 'getProperties', () => ({
37
+ refetchInterval: refetchInterval.current,
38
+ }))
39
+
40
+ let captureCodec = $state('')
41
+ let captureDuration = $state(3)
42
+
43
+ const capture = createAudioCapturer(client)
44
+
45
+ const availableCodecs = $derived(
46
+ propertiesQuery.data?.supportedCodecs.length ? propertiesQuery.data.supportedCodecs : ['pcm16']
47
+ )
48
+
49
+ const selectedCodec = $derived(captureCodec || availableCodecs[0]!)
50
+ </script>
51
+
52
+ <ConnectionStatus {partID}>
53
+ {#snippet connected()}
54
+ <div class="p-4 pb-3">
55
+ <RefetchController
56
+ {refetchInterval}
57
+ queries={[propertiesQuery]}
58
+ />
59
+ </div>
60
+
61
+ <div class="flex flex-row divide-x">
62
+ <div class="flex w-full flex-col divide-y">
63
+ <MutationSection
64
+ title="GetAudio"
65
+ description="Capture audio from the device"
66
+ lastError={capture.error}
67
+ >
68
+ <div class="flex flex-col gap-2">
69
+ <Label cx="gap-1 text-xs">
70
+ Codec
71
+ <Select
72
+ slot="input"
73
+ value={selectedCodec}
74
+ on:change={(e) => {
75
+ captureCodec = (e.target as HTMLSelectElement).value
76
+ }}
77
+ >
78
+ {#each availableCodecs as codec (codec)}
79
+ <option value={codec}>{codec}</option>
80
+ {/each}
81
+ </Select>
82
+ </Label>
83
+ <Label cx="gap-1 text-xs">
84
+ Duration (seconds, 0 = stream until stopped)
85
+ <NumericInput
86
+ slot="input"
87
+ value={captureDuration}
88
+ on:change={(e) => {
89
+ captureDuration = numberValueFromEvent(e) ?? 3
90
+ }}
91
+ />
92
+ </Label>
93
+ </div>
94
+
95
+ <div class="mt-auto flex flex-col items-start gap-2">
96
+ {#if capture.status === 'recording'}
97
+ <p class="font-roboto-mono text-subtle-1 text-xs">
98
+ {(capture.totalBytes / 1024).toFixed(1)} kB captured
99
+ </p>
100
+ <Button
101
+ icon="stop-circle-outline"
102
+ onclick={capture.stop}
103
+ >
104
+ Stop
105
+ </Button>
106
+ {:else}
107
+ {#if capture.downloadUrl}
108
+ <p class="font-roboto-mono text-subtle-1 text-xs">
109
+ {(capture.totalBytes / 1024).toFixed(1)} kB captured
110
+ </p>
111
+ <a
112
+ href={capture.downloadUrl}
113
+ download="audio-capture.{selectedCodec}"
114
+ rel="external"
115
+ >
116
+ <Button icon="download">Download</Button>
117
+ </a>
118
+ {/if}
119
+ <Button
120
+ icon="play-circle-outline"
121
+ onclick={() => capture.start(selectedCodec, captureDuration)}
122
+ disabled={!client.current}
123
+ >
124
+ {capture.status === 'done' ? 'Capture Again' : 'Start Capture'}
125
+ </Button>
126
+ {/if}
127
+ </div>
128
+ </MutationSection>
129
+ </div>
130
+
131
+ <div class="ml-auto flex w-full max-w-1/2 flex-col divide-y sm:max-w-1/3">
132
+ <ApiSection
133
+ title="GetProperties"
134
+ description="Audio input properties"
135
+ class="relative"
136
+ >
137
+ <Query
138
+ query={propertiesQuery}
139
+ contentCx="h-6"
140
+ >
141
+ {#if propertiesQuery.data !== undefined}
142
+ <Properties
143
+ supportedCodecs={propertiesQuery.data.supportedCodecs}
144
+ sampleRateHz={propertiesQuery.data.sampleRateHz}
145
+ numChannels={propertiesQuery.data.numChannels}
146
+ />
147
+ {/if}
148
+ </Query>
149
+ </ApiSection>
150
+ </div>
151
+ </div>
152
+ {/snippet}
153
+ </ConnectionStatus>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ partID: string;
3
+ resourceName: string;
4
+ }
5
+ declare const AudioInput: import("svelte").Component<Props, {}, "">;
6
+ type AudioInput = ReturnType<typeof AudioInput>;
7
+ export default AudioInput;
@@ -0,0 +1,13 @@
1
+ import { AudioInClient } from '@viamrobotics/sdk';
2
+ type CaptureStatus = 'idle' | 'recording' | 'done' | 'error';
3
+ export declare const createAudioCapturer: (client: {
4
+ current: AudioInClient | undefined;
5
+ }) => {
6
+ readonly status: CaptureStatus;
7
+ readonly error: Error | null;
8
+ readonly totalBytes: number;
9
+ readonly downloadUrl: string | undefined;
10
+ start: (codec: string, duration: number) => Promise<void>;
11
+ stop: () => void;
12
+ };
13
+ export {};
@@ -0,0 +1,85 @@
1
+ import { AudioInClient } from '@viamrobotics/sdk';
2
+ export const createAudioCapturer = (client) => {
3
+ let captureStatus = $state('idle');
4
+ let captureError = $state(null);
5
+ let captureTotalBytes = $state(0);
6
+ let captureDownloadUrl = $state();
7
+ let captureAbortController = $state.raw();
8
+ $effect(() => {
9
+ return () => {
10
+ if (captureDownloadUrl)
11
+ URL.revokeObjectURL(captureDownloadUrl);
12
+ };
13
+ });
14
+ const start = async (codec, duration) => {
15
+ if (!client.current)
16
+ return;
17
+ captureStatus = 'recording';
18
+ captureError = null;
19
+ captureTotalBytes = 0;
20
+ if (captureDownloadUrl) {
21
+ URL.revokeObjectURL(captureDownloadUrl);
22
+ captureDownloadUrl = undefined;
23
+ }
24
+ const controller = new AbortController();
25
+ captureAbortController = controller;
26
+ const chunks = [];
27
+ try {
28
+ const stream = client.current.getAudio(codec, duration, 0n, {}, { ...client.current.callOptions, signal: controller.signal });
29
+ for await (const chunk of stream) {
30
+ chunks.push(chunk.audioData);
31
+ captureTotalBytes += chunk.audioData.byteLength;
32
+ }
33
+ captureStatus = 'done';
34
+ }
35
+ catch (error) {
36
+ if (controller.signal.aborted) {
37
+ captureStatus = 'done';
38
+ }
39
+ else {
40
+ captureStatus = 'error';
41
+ captureError = error instanceof Error ? error : new Error(String(error));
42
+ }
43
+ }
44
+ finally {
45
+ captureAbortController = undefined;
46
+ }
47
+ if (chunks.length > 0) {
48
+ const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
49
+ const merged = new Uint8Array(totalLength);
50
+ let offset = 0;
51
+ for (const chunk of chunks) {
52
+ merged.set(chunk, offset);
53
+ offset += chunk.byteLength;
54
+ }
55
+ const mimeTypes = {
56
+ mp3: 'audio/mpeg',
57
+ wav: 'audio/wav',
58
+ aac: 'audio/aac',
59
+ opus: 'audio/ogg',
60
+ flac: 'audio/flac',
61
+ };
62
+ const mimeType = mimeTypes[codec] ?? 'audio/octet-stream';
63
+ captureDownloadUrl = URL.createObjectURL(new Blob([merged], { type: mimeType }));
64
+ }
65
+ };
66
+ const stop = () => {
67
+ captureAbortController?.abort();
68
+ };
69
+ return {
70
+ get status() {
71
+ return captureStatus;
72
+ },
73
+ get error() {
74
+ return captureError;
75
+ },
76
+ get totalBytes() {
77
+ return captureTotalBytes;
78
+ },
79
+ get downloadUrl() {
80
+ return captureDownloadUrl;
81
+ },
82
+ start,
83
+ stop,
84
+ };
85
+ };
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ supportedCodecs: string[]
4
+ sampleRateHz: number
5
+ numChannels: number
6
+ }
7
+
8
+ const { supportedCodecs, sampleRateHz, numChannels }: Props = $props()
9
+ </script>
10
+
11
+ <dl class="font-roboto-mono flex flex-col gap-y-2 text-sm">
12
+ <div class="flex flex-col gap-0.5">
13
+ <dt class="text-default w-50 min-w-50 pr-2 font-medium">Supported Codecs</dt>
14
+ <dd class="text-subtle-1">
15
+ {supportedCodecs.length > 0 ? supportedCodecs.join(', ') : 'None'}
16
+ </dd>
17
+ </div>
18
+ <div class="flex flex-col gap-0.5">
19
+ <dt class="text-default w-50 min-w-50 pr-2 font-medium">Sample Rate</dt>
20
+ <dd class="text-subtle-1">{sampleRateHz} Hz</dd>
21
+ </div>
22
+ <div class="flex flex-col gap-0.5">
23
+ <dt class="text-default w-50 min-w-50 pr-2 font-medium">Channels</dt>
24
+ <dd class="text-subtle-1">{numChannels}</dd>
25
+ </div>
26
+ </dl>
@@ -0,0 +1,8 @@
1
+ interface Props {
2
+ supportedCodecs: string[];
3
+ sampleRateHz: number;
4
+ numChannels: number;
5
+ }
6
+ declare const Properties: import("svelte").Component<Props, {}, "">;
7
+ type Properties = ReturnType<typeof Properties>;
8
+ export default Properties;
@@ -0,0 +1,200 @@
1
+ <script lang="ts">
2
+ import { Button, Label, NumericInput, Select } from '@viamrobotics/prime-core'
3
+ import { AudioOutClient } from '@viamrobotics/sdk'
4
+ import { createResourceClient, createResourceQuery } from '@viamrobotics/svelte-sdk'
5
+
6
+ import ApiSection from '../../api-section.svelte'
7
+ import ConnectionStatus from '../../connection-status.svelte'
8
+ import MutationSection from '../../mutation-section.svelte'
9
+ import Query from '../../query.svelte'
10
+ import RefetchController from '../../refetch-controller.svelte'
11
+ import { createRefetchIntervalStore } from '../../refetch-interval-store.svelte'
12
+ import { numberValueFromEvent } from '../../../event-handlers'
13
+
14
+ import { ExtToCodec, MimeToCodec } from './codec.js'
15
+ import { createAudioPlayer } from './create-audio-player.svelte.js'
16
+ import Properties from './properties.svelte'
17
+
18
+ interface Props {
19
+ partID: string
20
+ resourceName: string
21
+ }
22
+
23
+ const { partID, resourceName }: Props = $props()
24
+
25
+ const refetchInterval = createRefetchIntervalStore(
26
+ () => partID,
27
+ () => resourceName,
28
+ 'audio-output'
29
+ )
30
+
31
+ const client = createResourceClient(
32
+ AudioOutClient,
33
+ () => partID,
34
+ () => resourceName
35
+ )
36
+
37
+ const propertiesQuery = createResourceQuery(client, 'getProperties', () => ({
38
+ refetchInterval: refetchInterval.current,
39
+ }))
40
+
41
+ const availableCodecs = $derived(
42
+ propertiesQuery.data?.supportedCodecs.length ? propertiesQuery.data.supportedCodecs : ['pcm16']
43
+ )
44
+
45
+ const playContext = createAudioPlayer(client)
46
+
47
+ let selectedFile = $state.raw<File | null>(null)
48
+ let fileInputError = $state<string | null>(null)
49
+ let playCodec = $state('')
50
+ let playSampleRateHz = $state<number | null>(null)
51
+ let playNumChannels = $state<number | null>(null)
52
+
53
+ const selectedCodec = $derived(playCodec || availableCodecs[0]!)
54
+ const selectedSampleRateHz = $derived(
55
+ playSampleRateHz ?? propertiesQuery.data?.sampleRateHz ?? 48000
56
+ )
57
+ const selectedNumChannels = $derived(playNumChannels ?? propertiesQuery.data?.numChannels ?? 1)
58
+
59
+ const handleFileChange = (event: Event) => {
60
+ const input = event.target as HTMLInputElement
61
+ const file = input.files?.[0] ?? null
62
+ selectedFile = file
63
+ fileInputError = null
64
+ if (file) {
65
+ const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
66
+ if (MimeToCodec[file.type]) {
67
+ playCodec = MimeToCodec[file.type]
68
+ } else if (MimeToCodec[`audio/${ext}`]) {
69
+ playCodec = MimeToCodec[`audio/${ext}`]
70
+ } else if (ExtToCodec[ext]) {
71
+ playCodec = ExtToCodec[ext]
72
+ } else {
73
+ fileInputError = 'Unsupported audio format'
74
+ }
75
+ }
76
+ }
77
+
78
+ const play = async () => {
79
+ if (!selectedFile) {
80
+ fileInputError = 'Please select an audio file'
81
+ return
82
+ }
83
+ if (!client.current) return
84
+
85
+ const buffer = await selectedFile.arrayBuffer()
86
+ const audioData = new Uint8Array(buffer)
87
+ await playContext.play(audioData, selectedCodec, selectedSampleRateHz, selectedNumChannels)
88
+ }
89
+
90
+ const playButtonLabel = $derived.by(() => {
91
+ if (playContext.status === 'playing') return 'Playing...'
92
+ if (playContext.status === 'done') return 'Play Again'
93
+ return 'Play'
94
+ })
95
+ </script>
96
+
97
+ <ConnectionStatus {partID}>
98
+ {#snippet connected()}
99
+ <div class="p-4 pb-3">
100
+ <RefetchController
101
+ {refetchInterval}
102
+ queries={[propertiesQuery]}
103
+ />
104
+ </div>
105
+
106
+ <div class="flex flex-row divide-x">
107
+ <div class="flex w-full flex-col divide-y">
108
+ <MutationSection
109
+ title="Play"
110
+ description="Send audio data to the device"
111
+ lastError={playContext.error}
112
+ >
113
+ <div class="flex flex-col gap-2">
114
+ <Label cx="gap-1 text-xs">
115
+ Audio file
116
+ <input
117
+ slot="input"
118
+ type="file"
119
+ accept={availableCodecs.map((c) => `.${c}`).join(',')}
120
+ class="text-xs"
121
+ onchange={handleFileChange}
122
+ />
123
+ </Label>
124
+ {#if fileInputError}
125
+ <p class="text-xs text-red-500">{fileInputError}</p>
126
+ {/if}
127
+ {#if selectedFile}
128
+ <p class="font-roboto-mono text-subtle-1 text-xs">{selectedFile.name}</p>
129
+ {/if}
130
+ <Label cx="gap-1 text-xs">
131
+ Codec
132
+ <Select
133
+ slot="input"
134
+ value={selectedCodec}
135
+ on:change={(e) => {
136
+ playCodec = (e.target as HTMLSelectElement).value
137
+ }}
138
+ >
139
+ {#each availableCodecs as codec (codec)}
140
+ <option value={codec}>{codec}</option>
141
+ {/each}
142
+ </Select>
143
+ </Label>
144
+ <Label cx="gap-1 text-xs">
145
+ Sample rate (Hz)
146
+ <NumericInput
147
+ slot="input"
148
+ value={selectedSampleRateHz}
149
+ on:change={(e) => {
150
+ playSampleRateHz = numberValueFromEvent(e) ?? null
151
+ }}
152
+ />
153
+ </Label>
154
+ <Label cx="gap-1 text-xs">
155
+ Channels
156
+ <NumericInput
157
+ slot="input"
158
+ value={selectedNumChannels}
159
+ on:change={(e) => {
160
+ playNumChannels = numberValueFromEvent(e) ?? null
161
+ }}
162
+ />
163
+ </Label>
164
+ </div>
165
+
166
+ <div class="mt-auto">
167
+ <Button
168
+ icon="play-circle-outline"
169
+ onclick={play}
170
+ disabled={!client.current || playContext.status === 'playing'}
171
+ >
172
+ {playButtonLabel}
173
+ </Button>
174
+ </div>
175
+ </MutationSection>
176
+ </div>
177
+
178
+ <div class="ml-auto flex w-full max-w-1/2 flex-col divide-y sm:max-w-1/3">
179
+ <ApiSection
180
+ title="GetProperties"
181
+ description="Audio output properties"
182
+ class="relative"
183
+ >
184
+ <Query
185
+ query={propertiesQuery}
186
+ contentCx="h-6"
187
+ >
188
+ {#if propertiesQuery.data !== undefined}
189
+ <Properties
190
+ supportedCodecs={propertiesQuery.data.supportedCodecs}
191
+ sampleRateHz={propertiesQuery.data.sampleRateHz}
192
+ numChannels={propertiesQuery.data.numChannels}
193
+ />
194
+ {/if}
195
+ </Query>
196
+ </ApiSection>
197
+ </div>
198
+ </div>
199
+ {/snippet}
200
+ </ConnectionStatus>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ partID: string;
3
+ resourceName: string;
4
+ }
5
+ declare const AudioOutput: import("svelte").Component<Props, {}, "">;
6
+ type AudioOutput = ReturnType<typeof AudioOutput>;
7
+ export default AudioOutput;
@@ -0,0 +1,2 @@
1
+ export declare const MimeToCodec: Record<string, string>;
2
+ export declare const ExtToCodec: Record<string, string>;
@@ -0,0 +1,16 @@
1
+ export const MimeToCodec = {
2
+ 'audio/mpeg': 'mp3',
3
+ 'audio/mp3': 'mp3',
4
+ 'audio/wav': 'wav',
5
+ 'audio/x-wav': 'wav',
6
+ 'audio/aac': 'aac',
7
+ 'audio/ogg': 'opus',
8
+ 'audio/flac': 'flac',
9
+ 'audio/x-flac': 'flac',
10
+ };
11
+ // Raw PCM formats have no standard MIME type; map by file extension directly.
12
+ export const ExtToCodec = {
13
+ pcm16: 'pcm16',
14
+ pcm32: 'pcm32',
15
+ pcm32_float: 'pcm32_float',
16
+ };
@@ -0,0 +1,10 @@
1
+ import { AudioOutClient } from '@viamrobotics/sdk';
2
+ type PlayStatus = 'idle' | 'playing' | 'done' | 'error';
3
+ export declare const createAudioPlayer: (client: {
4
+ current: AudioOutClient | undefined;
5
+ }) => {
6
+ readonly status: PlayStatus;
7
+ readonly error: Error | null;
8
+ play: (audioData: Uint8Array, codec: string, sampleRateHz: number, numChannels: number) => Promise<void>;
9
+ };
10
+ export {};
@@ -0,0 +1,28 @@
1
+ import { AudioOutClient, commonApi } from '@viamrobotics/sdk';
2
+ export const createAudioPlayer = (client) => {
3
+ let playStatus = $state('idle');
4
+ let playError = $state(null);
5
+ const play = async (audioData, codec, sampleRateHz, numChannels) => {
6
+ if (!client.current)
7
+ return;
8
+ playStatus = 'playing';
9
+ playError = null;
10
+ try {
11
+ await client.current.play(audioData, commonApi.AudioInfo.fromJson({ codec, sampleRateHz, numChannels }), {});
12
+ playStatus = 'done';
13
+ }
14
+ catch (error) {
15
+ playStatus = 'error';
16
+ playError = error instanceof Error ? error : new Error(String(error));
17
+ }
18
+ };
19
+ return {
20
+ get status() {
21
+ return playStatus;
22
+ },
23
+ get error() {
24
+ return playError;
25
+ },
26
+ play,
27
+ };
28
+ };
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ supportedCodecs: string[]
4
+ sampleRateHz: number
5
+ numChannels: number
6
+ }
7
+
8
+ const { supportedCodecs, sampleRateHz, numChannels }: Props = $props()
9
+ </script>
10
+
11
+ <dl class="font-roboto-mono flex flex-col gap-y-2 text-sm">
12
+ <div class="flex flex-col gap-0.5">
13
+ <dt class="text-default w-50 min-w-50 pr-2 font-medium">Supported Codecs</dt>
14
+ <dd class="text-subtle-1">
15
+ {supportedCodecs.length > 0 ? supportedCodecs.join(', ') : 'None'}
16
+ </dd>
17
+ </div>
18
+ <div class="flex flex-col gap-0.5">
19
+ <dt class="text-default w-50 min-w-50 pr-2 font-medium">Sample Rate</dt>
20
+ <dd class="text-subtle-1">{sampleRateHz} Hz</dd>
21
+ </div>
22
+ <div class="flex flex-col gap-0.5">
23
+ <dt class="text-default w-50 min-w-50 pr-2 font-medium">Channels</dt>
24
+ <dd class="text-subtle-1">{numChannels}</dd>
25
+ </div>
26
+ </dl>
@@ -0,0 +1,8 @@
1
+ interface Props {
2
+ supportedCodecs: string[];
3
+ sampleRateHz: number;
4
+ numChannels: number;
5
+ }
6
+ declare const Properties: import("svelte").Component<Props, {}, "">;
7
+ type Properties = ReturnType<typeof Properties>;
8
+ export default Properties;
@@ -0,0 +1,7 @@
1
+ import { MLModelClient, ResourceName } from '@viamrobotics/sdk';
2
+ import { clientMap } from '../../../client-map';
3
+ type DoCommandable = InstanceType<Exclude<(typeof clientMap)[keyof typeof clientMap], typeof MLModelClient>>;
4
+ export declare const createDoCommandClient: (resource: () => ResourceName, partID: () => string, resourceName: () => string) => {
5
+ current: DoCommandable | undefined;
6
+ };
7
+ export {};
@@ -0,0 +1,26 @@
1
+ import { MachineConnectionEvent, MLModelClient, ResourceName } from '@viamrobotics/sdk';
2
+ import { useConnectionStatus, useRobotClient } from '@viamrobotics/svelte-sdk';
3
+ import { clientForBuiltinResource, clientMap } from '../../../client-map';
4
+ export const createDoCommandClient = (resource, partID, resourceName) => {
5
+ const robotClient = useRobotClient(partID);
6
+ const connectionStatus = useConnectionStatus(partID);
7
+ const resourceClient = $derived.by(() => {
8
+ if (!robotClient.current)
9
+ return;
10
+ if (connectionStatus.current !== MachineConnectionEvent.CONNECTED)
11
+ return;
12
+ const constructor = clientForBuiltinResource(resource());
13
+ if (!constructor)
14
+ return;
15
+ if (constructor === MLModelClient)
16
+ return;
17
+ const nextClient = new constructor(robotClient.current, resourceName());
18
+ nextClient.partID = partID();
19
+ return nextClient;
20
+ });
21
+ return {
22
+ get current() {
23
+ return resourceClient;
24
+ },
25
+ };
26
+ };
@@ -1,14 +1,15 @@
1
1
  <script lang="ts">
2
2
  import { Button, Progress } from '@viamrobotics/prime-core'
3
- import { JsonEditor } from '@viamrobotics/prime-editor'
4
- import { MLModelClient, ResourceName, Struct } from '@viamrobotics/sdk'
5
- import { createResourceClient, createResourceMutation } from '@viamrobotics/svelte-sdk'
3
+ import { CodeEditor } from '@viamrobotics/prime-core/code-editor'
4
+ import { type ResourceName, Struct } from '@viamrobotics/sdk'
5
+ import { createResourceMutation } from '@viamrobotics/svelte-sdk'
6
6
  import { PersistedState } from 'runed'
7
7
 
8
- import { clientForBuiltinResource } from '../../../client-map'
8
+ import { supportsDoCommand } from '../../../client-map'
9
9
  import { getResourceAPI, getResourceKey } from '../../../resource'
10
10
 
11
11
  import ErrorDisplay from '../../error.svelte'
12
+ import { createDoCommandClient } from './create-do-command-client.svelte'
12
13
 
13
14
  interface Props {
14
15
  partID: string
@@ -17,19 +18,15 @@
17
18
 
18
19
  const { partID, resource }: Props = $props()
19
20
 
20
- type DoCommandable = Extract<ReturnType<typeof clientForBuiltinResource>, MLModelClient>
21
-
22
- const resourceClientType = $derived(clientForBuiltinResource(resource)) as DoCommandable
23
-
24
- const client = $derived(
25
- createResourceClient(
26
- resourceClientType,
27
- () => partID,
28
- () => resource.name
29
- )
21
+ const client = createDoCommandClient(
22
+ () => resource,
23
+ () => partID,
24
+ () => resource.name
30
25
  )
31
26
 
32
- const doCommandMutation = $derived(createResourceMutation(client, 'doCommand'))
27
+ const doCommandMutation = createResourceMutation(client, 'doCommand')
28
+
29
+ const isSupported = $derived(supportsDoCommand(resource))
33
30
 
34
31
  let lastErr = $state<Error | null>()
35
32
 
@@ -53,17 +50,18 @@
53
50
  }
54
51
  </script>
55
52
 
56
- {#if doCommandMutation}
53
+ {#if isSupported}
57
54
  <div class="flex flex-row items-center justify-between">
58
55
  <div class="flex w-[45%] flex-col gap-2 border-r py-2">
59
56
  <span class="text-gray-9 px-4 text-sm font-medium">Input</span>
60
- <JsonEditor
57
+ <CodeEditor
61
58
  label="input"
62
- initialValue={input.current ?? '{}'}
59
+ language="json"
60
+ value={input.current ?? '{}'}
63
61
  onChange={(nextInput: string) => {
64
62
  input.current = nextInput
65
63
  }}
66
- cx="h-56 overflow-y-auto"
64
+ class="h-56 overflow-y-auto"
67
65
  errorMessageID={lastErr ? uid : undefined}
68
66
  />
69
67
  </div>
@@ -84,11 +82,12 @@
84
82
  {/if}
85
83
  </div>
86
84
  {#if !lastErr}
87
- <JsonEditor
85
+ <CodeEditor
88
86
  label="output"
89
- initialValue={output}
87
+ language="json"
88
+ value={output}
90
89
  readonly
91
- cx="h-56 overflow-y-auto"
90
+ class="h-56 overflow-y-auto"
92
91
  />
93
92
  {:else}
94
93
  <ErrorDisplay
@@ -1,4 +1,4 @@
1
- import { ResourceName } from '@viamrobotics/sdk';
1
+ import { type ResourceName } from '@viamrobotics/sdk';
2
2
  interface Props {
3
3
  partID: string;
4
4
  resource: ResourceName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viamrobotics/test-widgets",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "files": [
@@ -31,7 +31,6 @@
31
31
  "@threlte/core": ">=8",
32
32
  "@threlte/extras": ">=9",
33
33
  "@viamrobotics/prime-core": ">=0.1",
34
- "@viamrobotics/prime-editor": ">=0.0.2",
35
34
  "@viamrobotics/sdk": ">=0.68",
36
35
  "@viamrobotics/svelte-sdk": ">=1.1",
37
36
  "lodash-es": ">=4",
@@ -55,8 +54,8 @@
55
54
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
56
55
  "@tailwindcss/postcss": "^4.2.4",
57
56
  "@tailwindcss/vite": "^4.2.4",
58
- "@tanstack/svelte-query": "^6.1.24",
59
- "@tanstack/svelte-query-devtools": "^6.1.24",
57
+ "@tanstack/svelte-query": "^6.1.25",
58
+ "@tanstack/svelte-query-devtools": "^6.1.25",
60
59
  "@testing-library/dom": "^10.4.1",
61
60
  "@testing-library/jest-dom": "^6.9.1",
62
61
  "@testing-library/svelte": "^5.3.1",
@@ -68,9 +67,8 @@
68
67
  "@types/node": "^25.6.0",
69
68
  "@types/three": "^0.183.1",
70
69
  "@viamrobotics/motion-tools": "^1.19.1",
71
- "@viamrobotics/prime-core": "^0.1.19",
72
- "@viamrobotics/prime-editor": "^0.0.2",
73
- "@viamrobotics/sdk": "^0.68.2",
70
+ "@viamrobotics/prime-core": "^0.1.21",
71
+ "@viamrobotics/sdk": "^0.69.0",
74
72
  "@viamrobotics/svelte-sdk": "^1.2.1",
75
73
  "@viamrobotics/tailwind-config": "^1.0.0",
76
74
  "@viamrobotics/three": "^0.0.9",