diff --git a/firmware/fs/README.md b/firmware/fs/README.md new file mode 100644 index 0000000..49f3b66 --- /dev/null +++ b/firmware/fs/README.md @@ -0,0 +1,86 @@ +# FS Tooling + +This directory contains the tooling to build and flash the external LittleFS image used by the firmware. + +## Python Version + +Use Python 3.11. + +The filesystem/audio build tool is currently tested with Python 3.11. +Python 3.13 is not supported at the moment, especially on Windows, because some dependencies in the TTS toolchain may fall back to source builds and fail to install. + +In particular, the dependency chain around `kokoro`, `spacy`, `thinc`, and `blis` may fail to install on Windows when no compatible wheels are available. + +## Install Dependencies + +`ffmpeg` is required in addition to the Python packages. + +```sh +python3.11 -m venv venv +. venv/bin/activate +pip install -r requirements.txt +``` + +On Windows: + +```bat +py -3.11 -m venv venv +venv\Scripts\activate +pip install -r requirements.txt +``` + +## Build LittleFS Image + +```sh +./venv/bin/python build_lfs_audio.py +``` + +Example with imported WAV files and a custom system prompt YAML: + +```sh +./venv/bin/python build_lfs_audio.py \ + --wav-dir ./my_wavs \ + --sys-yaml ./sys_prompts.yaml +``` + +Notes: + +- `--wav-dir` imports all `.wav` files from the given directory into `/lfs/a` +- `--sys-yaml` overrides the default TTS prompt list for `/lfs/sys` +- generated audio is converted to mono 16 kHz PCM before it is written into the LittleFS image + +This generates: + +- `lfs_external_flash.hex` + +The default image layout is aligned to a 4 KiB flash page layout. + +After the build, the script also prints a usage summary for `/lfs/sys`, `/lfs/a`, and the combined image. + +Example: + +```text +Usage summary: + sys: files=4, raw=48.0 KiB, fs=56.0 KiB (14 blocks, standalone estimate) + audio: files=12, raw=224.0 KiB, fs=264.0 KiB (66 blocks, standalone estimate) + total: raw=272.0 KiB, fs=300.0 KiB (75 blocks in combined image) +``` + +`raw` is the sum of file sizes in the staging tree. +`fs` is the estimated or measured LittleFS space usage with the configured block size. + +## Flash Image + +On macOS/Linux: + +```sh +./program.sh +``` + +On Windows: + +```bat +program.bat +``` + +Both scripts use paths relative to their own location, so they can be started from any working directory. \ No newline at end of file diff --git a/firmware/fs/build_lfs_audio.py b/firmware/fs/build_lfs_audio.py index e2d55a5..4bd5f5c 100644 --- a/firmware/fs/build_lfs_audio.py +++ b/firmware/fs/build_lfs_audio.py @@ -15,6 +15,8 @@ DEFAULT_SAMPLE_RATE = 16000 DEFAULT_VOICE = "af_bella" DEFAULT_BLOCK_SIZE = 4096 DEFAULT_BLOCK_COUNT = 2048 +DEFAULT_READ_SIZE = 512 +DEFAULT_LOOKAHEAD_SIZE = 256 DEFAULT_FILTERS = [ "highpass=f=120", "lowpass=f=6000", @@ -26,6 +28,7 @@ DEFAULT_SYS_PROMPTS = [ {"id": "404", "text": "No sound sample was found on the device."}, {"id": "update", "text": "Firmware updated. Awaiting confirmation."}, {"id": "confirm", "text": "State confirmed."}, + {"id": "voltest", "text": "Volume test. This is a sample of the current volume level."}, ] @@ -129,18 +132,32 @@ def add_files_recursive(fs: LittleFS, local_path: Path, lfs_path: str = "/") -> add_files_recursive(fs, item, target_path) -def build_littlefs_hex(source_folder: Path, output_hex: Path, block_size: int, block_count: int, start_addr: int) -> None: +def build_littlefs_image(source_folder: Path, block_size: int, block_count: int) -> tuple[LittleFS, int]: fs = LittleFS( block_size=block_size, block_count=block_count, - read_size=256, + read_size=DEFAULT_READ_SIZE, prog_size=256, - lookahead_size=512, + lookahead_size=DEFAULT_LOOKAHEAD_SIZE, cache_size=4096, ) add_files_recursive(fs, source_folder) + used_blocks = 0 + lfs_buffer = fs.context.buffer + for idx in range(block_count): + offset = idx * block_size + block_data = lfs_buffer[offset : offset + block_size] + if any(byte != 0xFF for byte in block_data): + used_blocks += 1 + + return fs, used_blocks + + +def build_littlefs_hex(source_folder: Path, output_hex: Path, block_size: int, block_count: int, start_addr: int) -> int: + fs, used_blocks = build_littlefs_image(source_folder, block_size, block_count) + ih = IntelHex() lfs_buffer = fs.context.buffer @@ -151,6 +168,42 @@ def build_littlefs_hex(source_folder: Path, output_hex: Path, block_size: int, b ih.frombytes(block_data, offset=start_addr + offset) ih.tofile(str(output_hex), format="hex") + return used_blocks + + +def summarize_directory(directory: Path) -> tuple[int, int]: + if not directory.exists(): + return 0, 0 + + file_count = 0 + raw_bytes = 0 + for file_path in directory.rglob("*"): + if file_path.is_file(): + file_count += 1 + raw_bytes += file_path.stat().st_size + + return file_count, raw_bytes + + +def format_bytes(size: int) -> str: + units = ["B", "KiB", "MiB", "GiB"] + value = float(size) + for unit in units: + if value < 1024.0 or unit == units[-1]: + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024.0 + + +def report_directory_usage(label: str, directory: Path, block_size: int, block_count: int) -> None: + file_count, raw_bytes = summarize_directory(directory) + _, used_blocks = build_littlefs_image(directory, block_size, block_count) + fs_bytes = used_blocks * block_size + print( + f" {label}: files={file_count}, raw={format_bytes(raw_bytes)}, " + f"fs={format_bytes(fs_bytes)} ({used_blocks} blocks, standalone estimate)" + ) def parse_args() -> argparse.Namespace: @@ -203,7 +256,7 @@ def main() -> None: tts_count = build_sys_tts(prompts, out_sys, args.sample_rate) wav_count = import_wavs_to_a(wav_dir, out_a, args.sample_rate) - build_littlefs_hex( + total_used_blocks = build_littlefs_hex( source_folder=staging_root, output_hex=output_hex, block_size=args.block_size, @@ -212,6 +265,14 @@ def main() -> None: ) print(f"Done. TTS assets: {tts_count}, WAV imports: {wav_count}, HEX: {output_hex}") + print("Usage summary:") + report_directory_usage("sys", out_sys, args.block_size, args.block_count) + report_directory_usage("audio", out_a, args.block_size, args.block_count) + print( + f" total: raw={format_bytes(summarize_directory(staging_root)[1])}, " + f"fs={format_bytes(total_used_blocks * args.block_size)} " + f"({total_used_blocks} blocks in combined image)" + ) if not args.keep_staging and staging_root.exists(): shutil.rmtree(staging_root) diff --git a/firmware/libs/fs_mgmt/Kconfig b/firmware/libs/fs_mgmt/Kconfig index cbe2f8a..56d0dd2 100644 --- a/firmware/libs/fs_mgmt/Kconfig +++ b/firmware/libs/fs_mgmt/Kconfig @@ -58,13 +58,13 @@ if FS_MGMT endif # SOC_SERIES_NRF52X config FS_LITTLEFS_READ_SIZE - default 256 + default 512 config FS_LITTLEFS_PROG_SIZE default 256 config FS_LITTLEFS_CACHE_SIZE default 4096 config FS_LITTLEFS_LOOKAHEAD_SIZE - default 512 + default 256 module = FS_MGMT module-str = fs_mgmt