Skip to content

feat(voice): dynamic voice list with preview#2

Closed
heavygee wants to merge 13 commits into
mainfrom
feat/voice-picker
Closed

feat(voice): dynamic voice list with preview#2
heavygee wants to merge 13 commits into
mainfrom
feat/voice-picker

Conversation

@heavygee

@heavygee heavygee commented May 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a GET /api/voice/voices hub route that proxies the ElevenLabs /v1/voices endpoint, exposing all voices available to the configured API key — including user voice clones
  • Settings page voice picker now loads voices dynamically on mount instead of a hardcoded list
  • Each voice row now always shows a preview control:
    • enabled when preview_url exists
    • disabled with tooltip when preview is unavailable (including no-key fallback state)
  • Cloned voices are labelled with a "clone" badge so users can distinguish them
  • Falls back to a static built-in voice list if the hub has no API key configured
  • Voice selection routing is done via per-agent voice at token/agent creation (no runtime override)

Refs: tiann#686

Test plan

  • Open Settings → Voice Assistant → Voice picker
  • Confirm full ElevenLabs voice list loads (including cloned voices from your account)
  • Confirm cloned voices appear with a "clone" badge
  • Click ▶ on a voice with preview — audio preview plays/stops
  • Select a voice — next voice session uses that voice
  • With no ELEVENLABS_API_KEY set — picker falls back to static list, preview buttons are visible but disabled with tooltip, and a 400 toast appears on start
  • web/src/routes/settings/index.test.tsx passes (21/21)

heavygee and others added 13 commits May 25, 2026 10:46
Single canonical guide at docs/operator/AGENTS.md; merge=ours keeps
AGENTS.md deleted when syncing tiann/hapi. Upstream PR branches must
still be cut from upstream/main only.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a Voice dropdown in the Voice Assistant settings section,
mirroring the existing language picker pattern.

- New `web/src/lib/voices.ts` — curated list of 10 pre-made ElevenLabs
  voices with name, gender, and short description
- `VoiceSessionConfig` gains optional `voiceId` field
- `startRealtimeSession` and `VoiceContext.startVoice` read
  `hapi-voice-id` from localStorage and thread it through to the
  ElevenLabs `startSession` call via `overrides.tts.voice_id`
- `buildVoiceAgentConfig` enables `tts.voice_id` override in
  `platform_settings` alongside the existing `language` override
- Settings page renders the picker with Default + 10 named options;
  selection persists to localStorage immediately

Default path: when no voice is stored the existing `cgSgspJ2msm6clMCkdW9`
(Jessica) baked into the agent config is used unchanged.

NOTE: branch cut from fork main — re-cut from upstream/main before
opening a PR to tiann/hapi.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
- Add GET /api/voice/voices hub route proxying ElevenLabs /v1/voices
  (returns empty list gracefully when no API key configured)
- Add fetchVoices() to ApiClient and web API layer with VoiceInfo type
- Settings voice picker now fetches account voices dynamically on mount,
  including user's cloned voices (shown with "clone" badge)
- Falls back to static built-in voice list if API returns empty
- Add play/stop preview button per voice using ElevenLabs preview_url
- Fix voice_id → voiceId in ElevenLabs override (SDK camelCase)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
heavygee added a commit that referenced this pull request Jun 2, 2026
Address PR review (Major #2): the mutation onError was leaving the
optimistic message in the visible thread as `failed`, which duplicated
the restored composer text -- operator saw a failed bubble plus the
same text in the composer, and could stack a stale failed turn next
to a fresh send.

The composer-restore + inline alert is the single retry surface for
this failure mode now, so the optimistic row is dropped via
removeOptimisticMessage() in the same handler. retryMessage stays
exposed for any future code path that still produces failed rows.

Test pins the new behaviour: the row is removed (not flipped to
failed) on send failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

heavygee commented Jun 6, 2026

Copy link
Copy Markdown
Owner Author

Closing — superseded by upstream tiann#743 (feat/voice-selection-all-backends), which landed the multi-backend voice picker on tiann/hapi:main.

Verified the substantive bits already in upstream/main:

  • per-voice ElevenLabs agents (ELEVENLABS_VOICE_AGENT_MAP + voice-specific agent tests)
  • disabled-preview-without-key UX (settings test for "shows a disabled preview button with tooltip when previewUrl is missing")
  • voice picker UI (redesigned around voicePickerCatalog.ts)

The 8 fork-only files in this PR (.cursor/rules/operator-fork.mdc, .gitattributes, root AGENTS.md deletion, docs/operator/AGENTS.md, docs/operator-local-tooling.md, 3x docs/plans/2026-05-23-*.md) are already on heavygee/hapi:main from a1f6209 (2026-05-24); two are newer there. Nothing to salvage.

Branch feat/voice-picker will be deleted.

@heavygee heavygee closed this Jun 6, 2026
@heavygee heavygee deleted the feat/voice-picker branch June 6, 2026 11:34
heavygee added a commit that referenced this pull request Jun 10, 2026
- Web `CursorMigrationBanner` now renders a "Manual review needed"
  state for `cursorMigrationState === 'ambiguous'` (Major #1: caller
  was promoting the metadata flag but no UI surfaced it).
- Pin the md5-fixture contract for `workspaceHashFromPath`: raw,
  no-normalization, trailing-slash-distinct hashes computed via
  `printf '%s' <path> | md5sum` (Major #2: prevents algorithm drift
  that would silently revert path-priority discovery to fallback).
- Snapshot full candidate set BEFORE the canonical fast-path resolves
  a single drawer so the `migrator:transplanted` log reports the
  decision-time count, not a post-rm undercount (Minor #1).
- Warn log when canonical-path drawer is missing but readdir hands
  back exactly one candidate - regression-equivalent behaviour, but
  the size mismatch warrants a journalctl trail (path-normalization
  corner case the maintainer can grep for).
- Boundary test: `messageCount = 101` (first value above the skip
  threshold) engages the size sanity check, pinning the cutoff
  contract (Nit).
- Schema docstring on `cursorMigrationState` enum spelling out the
  banner contract per value (Nit).
- syncEngine `getHapiMessageCount` warn-logs `countMessages` throws
  instead of silently downgrading to 0 (would chronically disable
  the floor).

Drafted with claude-4.6-sonnet-thinking via Cursor; reviewed and
tested by the operator. tiann#873.

Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee added a commit that referenced this pull request Jun 13, 2026
…dismissed (HAPI Bot, PR tiann#896)

The previous state machine swallowed the migration banner if the
operator reloaded the page before clicking dismiss: the migration flag
was set on success, and on remount the init logic mapped a
flag-set/dismiss-not-set session to 'pre-migrated', a state the banner
explicitly refuses to render. Net effect: a migrated session never
prompted for affirmative dismissal.

Fixes:

- Drop the 'pre-migrated' state. The dismissal flag is now the only
  signal that suppresses the banner; the migration flag alone means
  'banner shows until dismissed' (now or after a reload).
- Sessions that had nothing to migrate (no v1 entries in localStorage)
  pre-emptively write BOTH flags - migrated AND dismissed - so the bot's
  banner-stickiness fix doesn't surface a banner that has nothing to
  announce on freshly-created v2 sessions.

Tests:

- New `reload-before-dismiss leaves the banner visible` test pins the
  fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2
  on the same session reads the localStorage flags and stays
  'completed'.
- New `opts fresh sessions out of the banner pre-emptively` test pins
  the no-v1-entries shortcut.
- Existing `does not re-migrate on a mount where the migrated flag is
  already set` updated to assert 'completed' (not the dropped
  'pre-migrated').
- Existing `skips migration when localStorage is empty` updated to
  assert the new 'dismissed' status + the banner-dismissed flag.
- Banner test for the 'pre-migrated -> nothing' case removed (the state
  no longer exists).

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant