Add speech-speed setting and fix repeat-step replay lock#2927
Open
lifeart wants to merge 8 commits into
Open
Conversation
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>
|
Frontend test coverage: 72.57% (+0.01% compared to 72.56% on base) |
- 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>
|
Frontend test coverage: 72.49% (-0.07% compared to 72.56% on base) |
…nd-repeat-replay # Conflicts: # frontend/tests/unit/services/audio-test.js
|
Frontend test coverage: 72.66% (-0.11% compared to 72.77% on base) |
…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>
|
Frontend test coverage: 72.69% (-0.08% compared to 72.77% on base) |
…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>
|
Frontend test coverage: 73.24% (+0.54% compared to 72.7% on base) |
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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.
audioPlaybackRatepreference inuser-data(localStorage, same pattern aslocale); corrupt/unknown values fall back to1./api/audio?text=URLs load asAudioBuffers), with browser TTS as a fallback. The rate is applied to both, and the progress-bar / safety-net timeout durations are scaled accordingly.AudioBufferSourceNode.playbackRatepitch-shifts (chipmunk/slow-mo). When rate ≠ 1, clips are instead played through an<audio>element withpreservesPitch(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 ignorespreservesPitch.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) andmodestayedINTERACT(so the tab was "active" and un-clickable).heardWordsfor a clean fresh pass.allOptionsHeardintact); 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, andember-template-lintall pass clean. The QUnit suite was not run —ember testis broken pre-existing in this repo (aborts on a global./packagemodule error before any test runs).Worth a manual browser check
<audio>.play()during auto-advancing Listen mode (should be covered by the lesson's sticky activation, same basis as the existingcontext.resume()).🤖 Generated with Claude Code