# EchoFlow: a sentence-level listening practice player built on your own audio files

> A zero-dependency static site where you import an mp3 and an lrc file, click any line to hear it from that point, and keep the audio in your own browser. The Docker image adds one optional sync endpoint.

**luzhenhua/echo-flow** — Online sentence-by-sentence reader for English audio files - click a sentence to play it, with continuous playback, supporting EN / EN+CN / CN modes.

- Repository: https://github.com/luzhenhua/echo-flow
- Website: https://echoflow.zhenhua.lu
- Stars: 2,297 · Forks: 404
- Language: JavaScript
- License: MIT
- Published: 2026-10-08 · Updated: 2026-10-08 · Language: en
- Canonical page: https://hysenlabs.com/projects/luzhenhua-echo-flow

## What the tool actually is

EchoFlow is a listening and shadowing practice tool. The repository description and the readme title frame it as point-read and follow-along practice: you bring your own mp3 audio and lrc subtitles, and the player highlights and plays sentence by sentence.

The important thing about this project is what it is not. There is no bundled audio and no bundled text. The readme states this in a prominent warning near the top, and again in a disclaimer section further down, and the disclaimers add the practical constraints: imported audio and subtitles stay in your own browser through IndexedDB, nothing is uploaded to the server, copyright stays with the original rights holders, and use is for personal study rather than commercial redistribution. That framing is unusual and it shapes everything else about the project, including the deployment story.

The feature list is compact enough to state fully. Import supports a folder in bulk, multi-file selection, or a zip archive. Playback is sentence-level, with the active line highlighted as it plays. The display has three modes, English only, English with Chinese alongside, and Chinese only, which tells you the intended learner is Chinese-speaking and the original purpose was textbook English. Controls cover playback speed, a switch between continuous reading and point reading, a loop mode, and resuming from the last position.

There are also global keyboard shortcuts for play and pause, arrow-key navigation and volume, course favorites, a study history and progress tracking, light and dark themes, and a responsive layout. And the whole thing describes itself as zero dependency, pure static files, unzip and run.

## The one command that gets you running

Deployment is the shortest part of the readme, and the first option is a single command with no code download at all.

```bash
docker run -d -p 8080:80 --name echo-flow --restart unless-stopped luzhenhua/echo-flow:latest
```

Then you open localhost:8080. Changing the host port is a matter of editing the mapping.

```bash
docker run -d -p 3000:80 --name echo-flow --restart unless-stopped luzhenhua/echo-flow:latest
```

The compose file is the second option, for when you want the data volume.

```yaml
services:
  echo-flow:
    build: .
    container_name: echo-flow
    ports:
      - "8080:80"
    restart: unless-stopped
    volumes:
      - ./data:/app/data
```

That single volume mount is the whole reason the container exists rather than just serving files. The comment next to it explains that mounting it makes user data persist on the host under ./data, so clearing your browser cache does not lose it. There is also a commented block for an access token.

```yaml
    # environment:
    #   - NCE_DATA_TOKEN=change-me
```

When a token is set, the readme says you must enter the same token in the application's settings pane before syncing works. The compose file also defines a healthcheck that spiders the root URL every thirty seconds with a forty second start period, and pins a bridge network named echo-network.

So there are three deployment shapes and they are not equivalent. The bare docker run gets you the player with no persistence. The compose file adds persistence plus optional token-gated sync. Serving the files yourself from any static host gets you the player with no persistence and no server-side code at all.

## The image is OpenResty, not a static file dump

This is the part of the design that is easy to miss from the readme's zero-dependency claim. The Dockerfile starts from an OpenResty Alpine base, and the comment says why: static serving as before, plus an optional data persistence endpoint.

```dockerfile
FROM openresty/openresty:alpine
```

The Lua directory in the tree exists for one handler, and the nginx configuration is copied in as a drop-in. Everything else is static files copied into the web root.

```dockerfile
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY lua /etc/nginx/lua
```

There is one genuinely clever line in the build, and it is worth reading twice if you plan to fork this.

```dockerfile
RUN chmod -R 755 /usr/share/nginx/html \
 && mkdir -p /app/data && chmod 777 /app/data \
 && sed -i '1i env NCE_DATA_TOKEN;' /usr/local/openresty/nginx/conf/nginx.conf
```

Nginx clears the environment for worker processes by default, so a Lua handler calling os.getenv on the token would always see nothing. Injecting an env directive as the first line of the main config is the supported workaround, and the comment says exactly that. It is a small hack that saves wiring a secrets mechanism into an image that otherwise has none.

The startup command has a related piece of pragmatism.

```dockerfile
CMD ["sh", "-c", "chmod 777 /app/data 2>/dev/null || true; exec openresty -g 'daemon off;'"]
```

Workers commonly run as nobody, and the permissions on a bind mount come from the host directory, which is often not writable by that user. Rather than fight it at every deployment, the container chmods the directory at startup and swallows the failure. Image labels carry the maintainer address, a description, the source URL and the MIT license identifier, so the image is self-describing in a registry.

## IndexedDB in the browser, and the Range request that matters

Two details in the readme will save you time if you hit them unprepared.

The first is that you cannot open index.html directly. The readme states that double-clicking the file fails to load data because of browser security policy, which is the standard origin restriction on file URLs. You need a server. Any static server will do, and the readme offers three ways: the Python built-in server, npx serve, or the Live Server extension in VS Code.

The second is a bug that looks like a feature is broken. The readme warns that some Python versions ship an http.server that does not support HTTP Range requests, and that the symptom is that clicking a sentence plays from the beginning instead of jumping to that point. If your player ignores sentence seeking, that is why, and the fix is to use Node, Docker or NGINX instead.

The reason is structural rather than accidental. Sentence-level seeking means asking the audio element for a byte offset, which is a Range request, and a server that ignores Range returns the whole file. A tool built around click-to-hear-a-line cannot work on a server that cannot do byte ranges, so the constraint is not incidental to the design, it is a requirement the deployment has to meet.

The third detail is the service worker. sw.js and manifest.json are in the tree, and the version history shows PWA support added in v1.4.8 in November 2025, with the install entry point fixed two releases later in v1.4.9. The readme does not say whether the service worker caches audio, and given that audio lives in IndexedDB rather than as files, that is worth checking before you assume offline listening works.

## What the release history reveals about real use

The project has no platform releases, and the version history lives as a list inside the readme instead, linked to the releases page for the full changelog. Reading that list is more informative than it has any right to be.

The first version, v1.0.0, is dated 2025-10-11. The list runs to v1.7.9 on 2026-02-06. That is about four months and roughly thirty documented entries, with several versions sharing a single day. The cadence says a solo maintainer iterating fast against their own device, which fits the fact that there are two open issues against 2292 stars.

The entries cluster into three eras. Early releases are infrastructure and compatibility: Docker support in v1.1.4, playback logic changes for iOS Safari in v1.1.1, stability fixes in v1.1.3. The middle run is interface work on mobile, including hiding the top and bottom navigation bars during use in v1.4.7 and v1.4.4, and a batch of fixes for auto-advancing to the next lesson without playing on iOS in v1.7.2.

The third era is feature work driven by actual study behavior. v1.4.4 added a smart skip of the leading silence with a toggle, which is the kind of thing only someone who has listened to the same material many times would build. v1.7.0 added lists, meaning bookmarking individual sentences and reading them back as a set. v1.7.5 added automatic shutdown after N consecutive lessons. v1.5.1 fixed local point-read jumping and the service worker precache, and v1.6.0 added follow-along mode with subtitle correction and audio pausing on page change.

Read together, the pattern is a tool being tuned by someone who studies with it daily. The readme also credits one outside contributor, a pull request adding playback speed controls and persistence of the chosen speed to the lesson page. Two open issues is consistent with a project whose feedback arrives through its chat channels rather than its tracker.

## Signals worth checking before you depend on it

A few numbers and structures to weigh. Stars are 2292 and forks 401, MIT licensed, and the most recent push to main is dated 2026-08-07. That is recent enough to treat the branch as in motion, but the newest version documented in the readme is v1.7.9 from February 2026, so there is a six month gap between the newest listed version and the newest commit. Either changes after v1.7.9 have not been written into the readme's history list, or work is happening on things other than versioning. The readme's own list is the thing to trust for versions and the repository's tags are the thing to trust for what is actually current.

The topic list is empty, which is a missed opportunity for a project with a distinctive name that also collides with a popular battery brand. Searching the name gets you power stations, and you can see that in the related search terms, which are all about battery products rather than listening practice. If you are looking for this project, go through the repository rather than a search engine.

The file tree is worth a look too. Beyond the four entries the readme documents, which are assets, index.html, lesson.html and README.md, there is about.html, book.html, donate.html and favorites.html, an icons directory, an images directory, a lua directory, DOCKER.md, build-and-push.sh, robots.txt and sitemap.xml. So the readme's project structure section is a simplified view of a site with more pages than it describes, and there is a donation page alongside the author link at the bottom of the readme.

The three documented source files are the compose file, the Dockerfile and a service worker reference. That is consistent with a project whose complexity lives in the assets directory rather than in a build pipeline, and it means there is no package manifest to read, no dependency tree to audit and no framework version to track. That is a genuine security advantage over most web tools you self-host, and it is also why there is no build step to slow you down.

## Conclusion

EchoFlow is worth using if you own the audio and lyrics and want a player that treats a paragraph as the unit of navigation rather than a waveform. That is a narrower goal than it sounds and the project is honest about it: no content ships with the tool, imports live in IndexedDB in your browser, and the readme is careful to say you must supply material you have the right to use. Two things to weigh before you host it. The static version needs a real HTTP server rather than opening index.html directly, and the readme specifically warns that the Python built-in server does not send Range requests, which breaks sentence seeking. And the Docker image is not purely static, it is OpenResty with a Lua handler behind an optional data endpoint, so if you only want the player, do not reach for the container.

## FAQ

### Does EchoFlow come with any textbook audio or content?

No, and the readme is explicit about this in both the warning near the top and the disclaimer section further down. EchoFlow ships no audio and no text. You supply your own mp3 and lrc files, the imported data stays in your own browser through IndexedDB, and nothing is uploaded to the server. The readme also states that copyright in imported content stays with the original rights holders, that use is for personal study rather than commercial redistribution, and that you should buy legitimate copies of any textbook material before using it.

### Why does clicking a sentence play the audio from the beginning?

Almost always because the server you are using does not support HTTP Range requests. EchoFlow seeks by asking the audio element for a byte offset, and a server that ignores Range sends the whole file instead, so the player silently falls back to playing from position zero. The readme calls out the Python built-in server specifically, noting that some Python versions do not send Range headers, and recommends Node, Docker or NGINX instead. Also make sure you are serving over HTTP rather than opening index.html directly from disk, since browsers block file URL data access.

### What does the Docker deployment add that a plain static host does not?

One thing: optional server-side persistence for your data. The image is built on OpenResty, which is Nginx with Lua, and copies in a custom nginx configuration plus a Lua handler that exposes a data endpoint. Without the mounted volume, the container is just a static file server with the same behavior as hosting the files anywhere. With ./data mounted to /app/data, user data persists on the host so clearing your browser cache does not lose it, and setting the NCE_DATA_TOKEN environment variable turns on token-gated syncing that you must also enter in the application settings.

## Sources

- [Issues](https://github.com/luzhenhua/echo-flow/issues)
- [License: MIT](https://github.com/luzhenhua/echo-flow/blob/main/LICENSE)
- [luzhenhua/echo-flow on GitHub](https://github.com/luzhenhua/echo-flow)
- [Project website](https://echoflow.zhenhua.lu)
- [README](https://github.com/luzhenhua/echo-flow/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/luzhenhua-echo-flow
