Skip to content

Add speech-speed setting and fix repeat-step replay lock#2927

Open
lifeart wants to merge 8 commits into
masterfrom
feat/speech-speed-and-repeat-replay
Open

Add speech-speed setting and fix repeat-step replay lock#2927
lifeart wants to merge 8 commits into
masterfrom
feat/speech-speed-and-repeat-replay

Conversation

@lifeart

@lifeart lifeart commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Two exercise UX improvements from lesson feedback.

1. Speech speed setting (0.5×–1.5×)

Users wanted to slow speech down (e.g. to 0.5×), and realized it mid-lesson — so the control lives in the in-lesson study-config panel where it's discoverable, and the value persists globally.

  • Persistence — new audioPlaybackRate preference in user-data (localStorage, same pattern as locale); corrupt/unknown values fall back to 1.
  • Applied to the real audio path — lessons play pre-recorded buffers (the /api/audio?text= URLs load as AudioBuffers), with browser TTS as a fallback. The rate is applied to both, and the progress-bar / safety-net timeout durations are scaled accordingly.
  • Pitch-preserving — Web Audio's AudioBufferSourceNode.playbackRate pitch-shifts (chipmunk/slow-mo). When rate ≠ 1, clips are instead played through an <audio> element with preservesPitch (time-stretch, natural pitch), re-encoding the decoded buffer to a WAV blob for format-agnostic playback. The default 1× path is unchanged (zero regression risk), pure-tone signal exercises are never diverted, and it degrades gracefully to pitch-shifted playback if a browser ignores preservesPitch.

2. Repeat ("повтор") step no longer locks after completion

Once every word was green, the repeat step was stuck — you had to switch to the Listen tab and back to replay. Root cause was two-fold: the interact loop returned on completion (so word-clicks did nothing) and mode stayed INTERACT (so the tab was "active" and un-clickable).

  • The repeat loop now stays live → any word is replayable, no lock.
  • Re-entering a completed step clears heardWords for a clean fresh pass.
  • The "Solve" step still unblocks/highlights once all words are heard (allOptionsHeard intact); progression stays manual by design.

Tests

Added/updated unit + integration tests: WAV encoder header/size, playback-rate routing (pitch-preserving vs. Web Audio path), rate persistence/fallback, the speech-rate selector, and repeat-step replay behavior.

Verification

lint:types (glint/tsc), eslint, and ember-template-lint all pass clean. The QUnit suite was not run — ember test is broken pre-existing in this repo (aborts on a global ./package module error before any test runs).

Worth a manual browser check

  • Autoplay policy for <audio>.play() during auto-advancing Listen mode (should be covered by the lesson's sticky activation, same basis as the existing context.resume()).
  • Time-stretch quality at 0.5× for a speech-training context.

🤖 Generated with Claude Code

Two exercise UX improvements requested from lesson feedback.

Speech speed (0.5×–1.5×):
- Persist an audioPlaybackRate preference in user-data (localStorage,
  like locale), with a discoverable selector in the in-lesson study
  config panel.
- Apply the rate to the audio the lessons actually use: pre-recorded
  buffers and the SpeechSynthesis fallback. Progress-bar and safety-net
  timeout durations are scaled with the rate.
- Preserve pitch when speed != 1 by playing the clip through an <audio>
  element (preservesPitch) instead of AudioBufferSourceNode.playbackRate,
  which pitch-shifts. Buffers are re-encoded to WAV for format-agnostic
  playback; the default 1× Web Audio path is unchanged. Pure-tone signal
  exercises are never diverted.

Repeat ("повтор") step replay:
- The step no longer locks once every word is heard: the interact loop
  stays live so words remain replayable, and re-entering a completed step
  clears heardWords for a fresh pass. Previously the only way to replay
  was to switch to the Listen tab and back.

Tests: WAV encoder, playback-rate routing, rate persistence/fallback,
and repeat-step replay behavior. Verified via lint:types, eslint, and
ember-template-lint (the ember test runner is broken pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Frontend test coverage: 72.57% (+0.01% compared to 72.56% on base)

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Gradle Unit and Integration Test Results

523 tests  ±0   521 ✔️ ±0   38s ⏱️ -2s
116 suites ±0       2 💤 ±0 
116 files   ±0       0 ±0 

Results for commit b49168d. ± Comparison against base commit 72f1a2c.

♻️ This comment has been updated with latest results.

- playBufferAtRate: set el.src first and set defaultPlaybackRate (not just
  playbackRate) so the speed change isn't silently reset to 1x on browsers
  (Safari) that apply defaultPlaybackRate on resource load.
- playBufferAtRate: resolve the ended promise when play() rejects (autoplay
  policy doesn't fire onerror), so the loop advances immediately instead of
  waiting out the full safety timeout in silence.
- task-player: document why the interact-mode await intentionally never
  resolves (loop runs until mode change; task is cancelled on leave/teardown).
- heard-words test: replace the tautological "mode never mutates" check with
  one that asserts a word is replayed after completion (the actual fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Frontend test coverage: 72.49% (-0.07% compared to 72.56% on base)

…nd-repeat-replay

# Conflicts:
#	frontend/tests/unit/services/audio-test.js
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Frontend test coverage: 72.66% (-0.11% compared to 72.77% on base)

lifeart and others added 2 commits July 1, 2026 18:27
…d select

Post-implementation UI review of the in-lesson speech-rate control:
- Hide it for pure-tone SIGNALS exercises, where playback goes through Tone
  synths and the rate has no effect (study-config.allowSpeechRate).
- Add a speedometer icon so the control is meaningful on mobile, where the
  text label is hidden to keep the exercise header compact.
- Use a controlled `value` binding on the <select> instead of per-option
  `selected` (the more robust Glimmer idiom; removes the eq helper).

Verified the layout in a browser at desktop and 375px mobile widths (fits
without overflow in both the speech-only and with-pictures/stacked cases).
Adds an integration test that the control is hidden for SIGNALS exercises;
the existing test still asserts the value binding via hasValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…immer)

CI's hasValue('1') failed with value={{this.playbackRate}} on <select> —
this Glimmer version does not apply a value binding to a select element.
Per-option selected={{eq}} works (it passed CI originally). Keeps the
gating + speedometer-icon improvements from the UI review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Frontend test coverage: 72.69% (-0.08% compared to 72.77% on base)

lifeart and others added 3 commits July 2, 2026 18:50
…tests

Correctness:
- exerciseSequenceTask is now restartable: keepLatest ENQUEUES the next
  perform behind the running instance (the previous comment claimed it
  cancelled it), and since the interact loop no longer self-terminates,
  a task change while on the repeat step queued a stale sequence that
  later yanked the user from Solve back into Listen. startTask guards
  the awaited perform against the (now possible) cancellation rejection.
- playBufferAtRate now reports whether playback actually started; on an
  autoplay-policy rejection (gesture-strict Safari) playTask falls back
  to Web Audio at the same rate — pitch-shifted but audible — instead of
  silently marking words as heard without a sound. AbortError from a
  routine stop is no longer logged as a playback failure, and a 'pause'
  listener releases the detached safety-race promptly on cancellation.
- nativePlayText resolves on utterance onerror too; a synthesis error
  used to leave playTask awaiting forever with every button disabled.
  It also takes the caller's rate snapshot so one pass can't mix speeds.

Robustness / cleanups:
- user-data: localStorage reads/writes are guarded (blocked-storage modes
  crashed service instantiation) and setAudioPlaybackRate validates
  against the preset list like the read side.
- WAV encodes are cached per source URL (bounded LRU) instead of
  re-encoding the clip on the main thread for every replay.
- Bypassed Web Audio nodes are disconnected at non-1x rates.
- Pitch-audio teardown is extracted into stopPitchAudioElement.
- Speed labels are localized (ru-ru sees "0,75×"); machine values stay
  dot-formatted. The hand-rolled speedometer SVG is replaced with the
  FontAwesome gauge-high icon used by the rest of the app.

Tests: the tautological repeat-step simulations are replaced with tests
that drive the real TaskPlayerComponent.interactModeTask (replay after
completion, fresh-pass reset, partial-progress preservation), plus
coverage for the Web Audio fallback, WAV blob caching, and setter
validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Glimmer components cannot be constructed manually — the base constructor
asserts that args is the manager-created proxy (ARGS_SET), so the unit
tests added in the previous commit failed in CI. Render the component
instead and capture the live instance via a test subclass, then drive
the real interactModeTask with waitUntil (settled-aware helpers would
hang on the loop's poll timer by design). The audio service's
setAudioElements/playAudio are patched to record exactly which words the
loop plays, immune to render-time URL building.

Covers: replay after completion (the lock regression), fresh-pass reset
on re-entry, and partial-progress preservation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
playAudio is an @action — a getter-only prototype accessor — so plain
instance assignment throws 'Cannot set property ... which has only a
getter'. defineProperty shadows it with an own data property.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Frontend test coverage: 73.24% (+0.54% compared to 72.7% on base)

@sonarqubecloud

sonarqubecloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

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