diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md
index b9c508c..ebc9cde 100644
--- a/docs/technical/architecture.md
+++ b/docs/technical/architecture.md
@@ -47,6 +47,7 @@ Keep the player and map octree outside the Rapier provider until there is a deli
- `src/managers/AudioManager.ts` provides pooled one-shot playback, looped music playback, category volumes, and optional stereo pan for one-shot sounds.
- Supported audio categories are `music`, `sfx`, and `dialogue`.
- Trigger interactions may play SFX directly through `AudioManager`.
+- Detailed audio documentation lives in `docs/technical/audio.md`.
## Settings Menu
diff --git a/docs/technical/audio.md b/docs/technical/audio.md
new file mode 100644
index 0000000..4c128ec
--- /dev/null
+++ b/docs/technical/audio.md
@@ -0,0 +1,217 @@
+# Audio Technical Notes
+
+This document describes the audio systems that exist in the current codebase.
+
+## Scope
+
+Audio is currently split into three runtime categories:
+
+- `music`: looped background music
+- `dialogue`: spoken dialogue audio linked to subtitles
+- `sfx`: one-shot interaction and feedback sounds
+
+The shared runtime service is `src/managers/AudioManager.ts`. User-facing volume settings live in `src/managers/stores/useSettingsStore.ts` and are forwarded to `AudioManager` by category.
+
+## AudioManager
+
+`AudioManager` is a singleton side-effect service. It owns browser audio elements, category volumes, pooled one-shot sounds, music playback, and stereo panning for one-shot sounds.
+
+Supported public methods:
+
+- `playMusic(path, volume)`: starts or updates a looped music track.
+- `stopMusic()`: stops the active music track.
+- `playSound(path, volume, options)`: plays a pooled one-shot sound and returns its `HTMLAudioElement`.
+- `setCategoryVolume(category, volume)`: updates `music`, `sfx`, or `dialogue` volume.
+- `getCategoryVolume(category)`: reads the current category volume.
+- `destroy()`: stops music, clears pools, closes the audio context, and resets the singleton.
+
+One-shot sounds are pooled by path with a maximum pool size per sound. If every element in a pool is busy, the pool grows until the limit, then recycles an existing element.
+
+Browser autoplay restrictions are handled in `playMusic()`: if playback is blocked by the browser, the manager waits for a user `pointerdown` or `keydown`, then retries the music.
+
+## Music
+
+Runtime music is mounted by `src/world/GameMusic.tsx`.
+
+Current behavior:
+
+- `GameMusic` calls `AudioManager.getInstance().playMusic()` on mount.
+- The current music path is `/sounds/musique/test.mp3`.
+- The base music volume is `0.33` before category volume is applied.
+- On unmount, `GameMusic` calls `stopMusic()`.
+
+Effective music volume is:
+
+```txt
+base music volume * settings music volume
+```
+
+Use `music` only for long-running looped background tracks. Do not use `playSound()` for music, because one-shot pooling is designed for short overlapping sounds.
+
+## Sound Effects
+
+SFX are short one-shot sounds. They should use `AudioManager.playSound()` with the default category or with `{ category: "sfx" }`.
+
+Example:
+
+```ts
+AudioManager.getInstance().playSound("/sounds/sfx/click.mp3", 0.8, {
+ category: "sfx",
+ pan: 0,
+});
+```
+
+Useful options:
+
+- `category`: `sfx` or `dialogue`; defaults to `sfx`.
+- `pan`: stereo panning from `-1` left to `1` right.
+- `playbackRate`: playback speed multiplier.
+
+SFX volume is controlled by the settings menu through the `sfx` category volume.
+
+## Dialogues
+
+Runtime dialogue data lives under `public/sounds/dialogue/`.
+
+```txt
+public/
+└── sounds/
+ └── dialogue/
+ ├── dialogues.json
+ └── subtitles/
+ ├── fr/
+ │ ├── narrateur.srt
+ │ ├── fermier.srt
+ │ └── electricienne.srt
+ └── en/
+ ├── narrateur.srt
+ ├── fermier.srt
+ └── electricienne.srt
+```
+
+The dialogue manifest shape is defined in `src/types/dialogues/dialogues.ts`.
+
+Each dialogue entry contains:
+
+- `id`: stable dialogue identifier
+- `voice`: voice group, currently `narrateur`, `fermier`, or `electricienne`
+- `audio`: runtime audio path
+- `subtitleCueIndex`: cue number inside that voice/language SRT file
+- `timecode`: optional global trigger time in seconds from scene start
+
+Dialogues are played through `src/utils/dialogues/playDialogue.ts`.
+
+Important functions:
+
+- `playDialogueById(manifest, dialogueId)`: plays a dialogue from an already loaded manifest.
+- `queueDialogueById(manifest, dialogueId)`: queues dialogue playback so multiple requests do not overlap.
+- `playGameplayDialogueById(dialogueId)`: loads the gameplay manifest once and queues a dialogue by ID.
+- `clearQueuedDialogues()`: resolves pending dialogue requests and clears the queue.
+
+Dialogue audio uses `AudioManager.playSound()` with `{ category: "dialogue" }`, so it follows the dialogue volume setting.
+
+## Dialogue And SRT Link
+
+The subtitle model is one SRT file per voice and language, not one SRT file per dialogue.
+
+A dialogue chooses its subtitle by combining:
+
+1. `voice`
+2. selected subtitle language from settings
+3. `subtitleCueIndex`
+
+For example, this dialogue:
+
+```json
+{
+ "id": "narrateur_bienvenueaaltera",
+ "voice": "narrateur",
+ "audio": "/sounds/dialogue/narrateur/bienvenueaaltera.mp3",
+ "subtitleCueIndex": 1
+}
+```
+
+loads cue `1` from:
+
+```txt
+public/sounds/dialogue/subtitles/fr/narrateur.srt
+```
+
+when the subtitle language is French, or from:
+
+```txt
+public/sounds/dialogue/subtitles/en/narrateur.srt
+```
+
+when the subtitle language is English.
+
+If the selected language is missing, the loader falls back to French. Missing English SRT files are warnings during validation, not runtime errors.
+
+SRT timecodes are relative to the dialogue audio file. They are not relative to the game clock and not relative to a cinematic timeline.
+
+## Subtitle Runtime
+
+`playDialogueById()` loads the matching subtitle cue with `loadDialogueSubtitleCue()` before playing the audio.
+
+While audio plays:
+
+- `timeupdate` checks `audio.currentTime`
+- the active subtitle is written to `useSubtitleStore`
+- `src/components/ui/Subtitles.tsx` renders the current speaker and text
+- `ended` and `pause` clear the subtitle
+
+The subtitle overlay respects settings from `useSettingsStore`, including visibility and selected language.
+
+## Global Timecode Dialogues
+
+`src/world/GameDialogues.tsx` loads the dialogue manifest and triggers entries that define `timecode`.
+
+This is useful for simple global scene timing. It should not be used for dialogue that belongs to a cinematic. Cinematic-owned dialogue should be triggered by `dialogueCues` in `public/cinematics.json` instead, otherwise the same dialogue can play twice.
+
+## Cinematic Dialogue Cues
+
+`public/cinematics.json` can include `dialogueCues`.
+
+Each cue contains:
+
+- `time`: seconds relative to the cinematic start
+- `dialogueId`: ID from `dialogues.json`
+
+`src/world/GameCinematics.tsx` uses those cues to play dialogue during camera timelines. This keeps camera movement and dialogue playback synchronized without relying on global scene time.
+
+## Editor Tooling
+
+The `/editor` route provides three audio-related tools:
+
+- `Dialogues`: edits `public/sounds/dialogue/dialogues.json` and previews dialogue playback.
+- `SRT`: edits one SRT file at a time and validates dialogue assets.
+- `Cinematics`: links dialogue IDs to cinematic timelines through `dialogueCues`.
+
+Dev-only Vite endpoints in `vite.config.ts` support local saves:
+
+- `POST /api/save-dialogues`
+- `POST /api/save-srt`
+- `GET /api/validate-dialogues`
+- `POST /api/save-cinematics`
+
+These endpoints are local development helpers. They are not production APIs.
+
+## Validation
+
+`GET /api/validate-dialogues` validates:
+
+- manifest shape
+- referenced dialogue audio files
+- French SRT files
+- referenced subtitle cue indexes
+- optional English SRT files as warnings
+
+Run validation after adding or renaming dialogue audio, changing cue indexes, or editing SRT files.
+
+## Known Limitations
+
+- There is no production persistence for audio manifests or SRT files.
+- Dialogue branching is not implemented.
+- Dialogue interruption and priority rules are minimal; playback is queue-based.
+- SRT editing is text-based and does not yet provide waveform editing.
+- Music currently supports one active looped track at a time.
diff --git a/docs/technical/editor.md b/docs/technical/editor.md
index 0e6c9b1..688c46b 100644
--- a/docs/technical/editor.md
+++ b/docs/technical/editor.md
@@ -52,7 +52,7 @@ src/
## Responsibilities
-`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, and player-mode toggle.
+`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state.
`src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads.
@@ -62,7 +62,7 @@ src/
`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
-`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas.
+`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. The panel is organized into top-level `details` groups: `Editor`, `Cinematics`, `Dialogues`, and `SRT`.
`src/components/editor/EditorDialogueManifestPanel.tsx` renders the dialogue manifest editor. It loads `dialogues.json`, edits dialogue entries, previews selected dialogue playback, creates missing French SRT cues, and saves the manifest through a dev-server endpoint.
@@ -122,13 +122,17 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
2. `useEditorSceneData` calls `loadMapSceneData()`.
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
4. If `/map.json` is missing, the page displays a folder-upload flow.
-5. `EditorScene` renders the grid, lights, camera controls, and map nodes.
-6. `EditorControls` exposes transform mode, history actions, export, save, and selection info.
+5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
+6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
+7. `EditorControls` exposes transform mode, history actions, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
## Controls
- Click: select a node.
- `Esc`: clear selection.
+- Click empty space: clear selection.
+- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection.
+- Selection clear button: intentionally clear the current selection even when the lock is active.
- `T`: translate mode.
- `R`: rotate mode.
- `S`: scale mode.
@@ -147,6 +151,51 @@ The editor supports two output paths:
The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It writes to `public/map.json` and enforces a maximum payload size.
+## Editor Loading Overlay
+
+The editor uses `SceneLoadingOverlay` like the runtime scene. `EditorSceneLoadingTracker` lives in `src/pages/editor/page.tsx` and reads drei `useProgress()` inside the canvas.
+
+The route tracks two loading phases:
+
+- map JSON loading through `useEditorSceneData()`
+- model loading through `useProgress()`
+
+The overlay is rendered outside the canvas so it remains visible while the R3F scene mounts. The scene itself is wrapped in `Suspense` with a `null` fallback; the visual feedback is handled by the overlay instead of by the canvas fallback.
+
+## Panel Groups
+
+`EditorControls` uses the local `EditorPanelGroup` helper to keep the side panel navigable as tools grow.
+
+Current group order:
+
+1. `Editor`
+2. `Cinematics`
+3. `Dialogues`
+4. `SRT`
+
+Inside the `Editor` group, the section order is:
+
+1. `Shortcuts`
+2. `Transform`
+3. `Selection`
+4. `View`
+5. `JSON`
+6. `File`
+
+The `Shortcuts` group is nested and closed by default to reduce visual noise.
+
+## Selection Lock
+
+Selection lock is owned by `EditorPage` through `isSelectionLocked`.
+
+The state is passed to:
+
+- `EditorControls`, to render the lock/unlock button
+- `EditorScene`, to block `Esc` deselection when locked
+- `EditorMap`, to block object selection and empty-space deselection when locked
+
+The clear button calls `onClearSelection` directly from `EditorControls`. This is intentionally separate from scene click behavior so the user always has an explicit way to clear the selection.
+
## Dialogue SRT Editing
Dialogue subtitle editing is part of the `/editor` side panel.
diff --git a/docs/user/editor.md b/docs/user/editor.md
index af2dd5c..58091f8 100644
--- a/docs/user/editor.md
+++ b/docs/user/editor.md
@@ -1,12 +1,18 @@
# Editor User Guide
-The map editor is available at `/editor`. It is a browser-based tool for inspecting and adjusting the objects listed in `public/map.json`.
+The map editor is available at `/editor`. It is a browser-based tool for editing the runtime map, cinematic manifest, dialogue manifest, and SRT subtitle files without manually jumping between JSON and subtitle files.
## Purpose
-Use the editor when you need to move, rotate, or scale existing map objects without editing JSON by hand.
+Use the editor when you need to:
-The editor reads the same map data as the runtime scene:
+- move, rotate, or scale objects from `public/map.json`
+- inspect the raw JSON generated by the editor
+- preview and edit cinematics from `public/cinematics.json`
+- create, preview, and validate dialogue entries from `public/sounds/dialogue/dialogues.json`
+- edit FR/EN SRT subtitle files per voice
+
+The map editor reads the same map data as the runtime scene:
- `public/map.json` contains the object list.
- `public/models/{name}/model.glb` contains the matching 3D model for each object name. `model.gltf` is still supported as a fallback during migration.
@@ -24,7 +30,18 @@ Each entry in `public/map.json` represents one object:
| `rotation` | Object rotation as `[x, y, z]`, expressed radians |
| `scale` | Object scale as `[x, y, z]` |
-## Editing Workflow
+## Panel Layout
+
+The right panel is split into dropdown groups:
+
+- `Editor`: map transform tools, shortcuts, selection, view mode, JSON preview, and file actions.
+- `Cinematics`: editor for `public/cinematics.json`.
+- `Dialogues`: editor for `public/sounds/dialogue/dialogues.json`.
+- `SRT`: editor for subtitle files in `public/sounds/dialogue/subtitles/`.
+
+Only the `Editor` group is open by default. Open the other groups when you need audio or cinematic tooling.
+
+## Map Editing Workflow
1. Open `/editor` in the local app.
2. Click an object in the scene to select it.
@@ -40,6 +57,8 @@ Each entry in `public/map.json` represents one object:
| -------------------- | -------------------------- |
| Select object | Click object |
| Deselect | `Esc` or click empty space |
+| Lock selection | `Lock` button in Selection |
+| Clear selection | `X` button in Selection |
| Translate mode | `T` |
| Rotate mode | `R` |
| Scale mode | `S` |
@@ -49,18 +68,34 @@ Each entry in `public/map.json` represents one object:
| Move up | `Space` |
| Move down | `Shift` |
+## Selection
+
+The `Selection` section shows the selected object name and its index in `public/map.json`.
+
+- Click an object to select it.
+- Click empty space or press `Esc` to clear the selection.
+- Use the `X` button to clear the selection explicitly.
+- Use the `Lock` button to protect the current selection while editing.
+
+When selection is locked:
+
+- clicking another object does not change the selection
+- clicking empty space does not clear the selection
+- pressing `Esc` does not clear the selection
+- the `X` button still clears the selection intentionally
+
## View Mode
The `Lock view` action switches the editor into a movement mode closer to the runtime player camera. Use it to navigate larger scenes while keeping the transform tools available.
## JSON Inspector
-The side panel includes a raw JSON inspector:
+The `JSON` section shows the raw map data that will be exported or saved:
- When no object is selected, it shows the full map node list.
- When an object is selected, it highlights the JSON lines for that object.
-This is useful for checking numeric transform values before saving or exporting.
+Use it to verify exact numeric transform values before saving or exporting. The JSON inspector is read-only; transform values are changed through the gizmo in the scene.
## Saving Changes
@@ -76,12 +111,27 @@ The button is hidden in production builds because production persistence is not
## Editing Dialogue Subtitles
-The side panel also includes dialogue tools for the dialogue manifest and SRT subtitles.
+The side panel includes two separate audio text tools:
+
+- `Dialogues` edits the dialogue manifest, which links dialogue IDs to audio files and SRT cue indexes.
+- `SRT` edits the actual subtitle text and cue timings.
+
+The important model is: one dialogue entry points to one cue inside one SRT file. The SRT file is grouped by voice and language, not by dialogue.
### Dialogue Manifest
Use the `Dialogues` panel to edit `public/sounds/dialogue/dialogues.json` without opening the JSON file manually.
+Each dialogue entry contains:
+
+| Field | Meaning |
+| ------------------ | ----------------------------------------------------------------- |
+| `id` | Unique dialogue ID used by cinematics and runtime triggers |
+| `voice` | Voice file group: `narrateur`, `fermier`, or `electricienne` |
+| `audio` | Runtime audio path, usually under `/sounds/dialogue/` |
+| `subtitleCueIndex` | Cue number inside the selected voice/language SRT file |
+| `timecode` | Optional global runtime trigger time, in seconds from scene start |
+
Available actions:
- `Reload` reloads the manifest from disk.
@@ -95,6 +145,19 @@ After using `Add`, save the manifest to keep the new dialogue entry. The generat
New dialogue audio paths start as placeholders such as `/sounds/dialogue/new_dialogue_24.mp3`. Replace them with real MP3 paths before validating the final asset set.
+Recommended workflow for a new dialogue:
+
+1. Open `Dialogues`.
+2. Click `Add`.
+3. Choose the correct `voice`.
+4. Replace the generated `id` with a readable stable ID.
+5. Replace the placeholder `audio` path with the real MP3 path.
+6. Check the generated `subtitleCueIndex`.
+7. Click `Create FR SRT cue` if the cue does not exist yet.
+8. Click `Save`.
+9. Open `SRT`, edit the cue text and timings, then save the SRT file.
+10. Run `Validate` from the SRT panel.
+
### SRT Editor
Use the `SRT` panel to edit one subtitle file at a time.
@@ -108,6 +171,8 @@ Use the `SRT` panel to edit one subtitle file at a time.
Each SRT file belongs to one voice, not one dialogue. Cue indexes must match the `subtitleCueIndex` values referenced by the dialogue manifest.
+SRT timings are relative to the dialogue audio file, not to the global game timeline and not to the cinematic timeline. For example, `00:00:01,000` means one second after that dialogue audio starts.
+
## Validating Dialogue Assets
Use `Validate` in the SRT panel to check the dialogue manifest and linked assets.
@@ -143,6 +208,17 @@ Dialogue cues define:
- `time`: seconds relative to the cinematic start
- `dialogueId`: an entry from `public/sounds/dialogue/dialogues.json`
+Recommended workflow for a cinematic:
+
+1. Open `Cinematics`.
+2. Select an existing cinematic or click `Add`.
+3. Set a stable `id`.
+4. Add or adjust camera keyframes.
+5. Keep keyframe `time` values increasing from start to end.
+6. Add dialogue cues when a dialogue must start during the camera sequence.
+7. Click `Preview cinematic` to test the camera path in the editor canvas.
+8. Click `Save` when the manifest is correct.
+
Available actions:
- `Reload` reloads the cinematic manifest from disk.
@@ -155,6 +231,8 @@ Available actions:
Cinematic dialogue cues are the preferred way to synchronize a dialogue with a cinematic. Avoid also giving the same dialogue a global `timecode`, or it can be triggered twice.
+Use `dialogueCues` when the dialogue belongs to a cinematic. Use a dialogue `timecode` only for simple global scene timing outside a cinematic.
+
## Current Limitations
- The editor only modifies existing nodes.
diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx
index b5cf0ee..e9b099d 100644
--- a/src/components/editor/EditorControls.tsx
+++ b/src/components/editor/EditorControls.tsx
@@ -1,6 +1,7 @@
import {
Box,
Braces,
+ ChevronDown,
Download,
Expand,
Keyboard,
@@ -11,6 +12,8 @@ import {
RotateCw,
Save,
Undo2,
+ Unlock,
+ X,
} from "lucide-react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
@@ -25,6 +28,9 @@ interface EditorControlsProps {
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
+ isSelectionLocked: boolean;
+ onSelectionLockToggle: () => void;
+ onClearSelection: () => void;
undoCount: number;
redoCount: number;
onUndo: () => void;
@@ -50,6 +56,33 @@ const EDITOR_SHORTCUTS = [
["WASD", "Move when locked"],
] as const;
+interface EditorPanelGroupProps {
+ title: string;
+ summary?: string;
+ defaultOpen?: boolean;
+ children: React.ReactNode;
+}
+
+function EditorPanelGroup({
+ title,
+ summary,
+ defaultOpen = false,
+ children,
+}: EditorPanelGroupProps): React.JSX.Element {
+ return (
+
+
+ {title}
+
+ {summary ? {summary} : null}
+
+
+
+
+
+
+
+ {onSaveToServer && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
>
);
diff --git a/src/components/editor/scene/EditorMap.tsx b/src/components/editor/scene/EditorMap.tsx
index baea5a6..b6eeedc 100644
--- a/src/components/editor/scene/EditorMap.tsx
+++ b/src/components/editor/scene/EditorMap.tsx
@@ -11,6 +11,7 @@ interface EditorMapProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
+ isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
@@ -28,6 +29,7 @@ interface EditorNodeCommonProps {
isHovered: boolean;
objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void;
+ isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void;
}
@@ -108,11 +110,13 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers(
index: number,
onSelectNode: (index: number | null) => void,
+ isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers {
return {
onClick: (event) => {
event.stopPropagation();
+ if (isSelectionLocked) return;
onSelectNode(index);
},
onPointerEnter: (event) => {
@@ -130,6 +134,7 @@ export function EditorMap({
sceneData,
selectedNodeIndex,
onSelectNode,
+ isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
transformMode,
@@ -192,8 +197,9 @@ export function EditorMap({
) => {
- e.stopPropagation();
+ onClick={(event: ThreeEvent) => {
+ event.stopPropagation();
+ if (isSelectionLocked) return;
onSelectNode(null);
}}
>
@@ -211,6 +217,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
+ isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
);
@@ -224,6 +231,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
+ isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
);
@@ -251,6 +259,7 @@ function EditorModelNode({
isHovered,
objectsMapRef,
onSelectNode,
+ isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & {
modelUrl: string;
@@ -269,6 +278,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
+ isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
@@ -343,12 +353,14 @@ function EditorFallbackNode({
isHovered,
objectsMapRef,
onSelectNode,
+ isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps) {
const meshRef = useRef(null);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
+ isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
diff --git a/src/components/editor/scene/EditorScene.tsx b/src/components/editor/scene/EditorScene.tsx
index c071ef0..c9d585c 100644
--- a/src/components/editor/scene/EditorScene.tsx
+++ b/src/components/editor/scene/EditorScene.tsx
@@ -17,6 +17,7 @@ interface EditorSceneProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
+ isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
@@ -35,6 +36,7 @@ export function EditorScene({
sceneData,
selectedNodeIndex,
onSelectNode,
+ isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
transformMode,
@@ -68,7 +70,7 @@ export function EditorScene({
if (selectedNodeIndex !== null) {
switch (e.key.toLowerCase()) {
case "escape":
- onSelectNode(null);
+ if (!isSelectionLocked) onSelectNode(null);
break;
case "t":
onTransformModeChange("translate");
@@ -85,7 +87,14 @@ export function EditorScene({
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]);
+ }, [
+ isSelectionLocked,
+ selectedNodeIndex,
+ onSelectNode,
+ onTransformModeChange,
+ onUndo,
+ onRedo,
+ ]);
return (
<>
@@ -113,6 +122,7 @@ export function EditorScene({
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode}
+ isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
diff --git a/src/data/docs/docsSections.ts b/src/data/docs/docsSections.ts
index 86bc869..8f25919 100644
--- a/src/data/docs/docsSections.ts
+++ b/src/data/docs/docsSections.ts
@@ -38,17 +38,23 @@ export const docGroups: DocGroup[] = [
subtitle: "Implementation details",
meta: "04",
},
+ {
+ path: "/docs/audio",
+ title: "Audio Technical Notes",
+ subtitle: "Music, dialogue, SRT, and SFX",
+ meta: "05",
+ },
{
path: "/docs/hand-tracking",
title: "Hand Tracking Technical Notes",
subtitle: "Webcam interaction pipeline",
- meta: "05",
+ meta: "06",
},
{
path: "/docs/zustand",
title: "Zustand Game State",
subtitle: "Progression store",
- meta: "06",
+ meta: "07",
},
],
},
@@ -59,25 +65,25 @@ export const docGroups: DocGroup[] = [
path: "/docs/features",
title: "Features",
subtitle: "Implemented scope",
- meta: "07",
+ meta: "08",
},
{
path: "/docs/main-feature",
title: "Main Feature",
subtitle: "Repair-game prototype",
- meta: "08",
+ meta: "09",
},
{
path: "/docs/editor",
title: "Editor User Guide",
subtitle: "Editing workflow",
- meta: "09",
+ meta: "10",
},
{
path: "/docs/animation",
title: "Animation & 3D Model System",
subtitle: "Components and usage",
- meta: "010",
+ meta: "11",
},
],
},
diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts
index edd79bb..d15eec0 100644
--- a/src/data/docs/docsTranslations.ts
+++ b/src/data/docs/docsTranslations.ts
@@ -556,142 +556,145 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
- séparation complète production / debug pour les scènes gameplay
`;
-export const editorFr = `# Éditeur de carte
+export const editorFr = `# Guide utilisateur de l'éditeur
-L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
+L'éditeur est disponible sur \`/editor\`. Il sert à modifier la carte runtime, les cinématiques, le manifeste de dialogues et les sous-titres SRT sans éditer tous les fichiers à la main.
-## Ce qui est édité
+## À quoi il sert
-L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
+Utilise l'éditeur pour :
-Chaque node décrit un objet de la scène :
+- déplacer, tourner ou scaler les objets de \`public/map.json\`
+- inspecter le JSON généré avant export ou sauvegarde
+- prévisualiser et modifier \`public/cinematics.json\`
+- créer, prévisualiser et valider les dialogues de \`public/sounds/dialogue/dialogues.json\`
+- modifier les fichiers SRT FR/EN par voix
-- "name" : nom du dossier modèle dans "/public/models/{name}/model.glb", avec fallback vers "model.gltf"
-- "type" : catégorie de l'objet
-- "position" : "[x, y, z]"
-- "rotation" : "[x, y, z]"
-- "scale" : "[x, y, z]"
+## Organisation du panneau
-Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
+Le panneau latéral est divisé en groupes repliables :
-## Workflow de base
+- \`Editor\` : transforms, raccourcis, sélection, vue, JSON et actions fichier.
+- \`Cinematics\` : cinématiques et keyframes caméra.
+- \`Dialogues\` : manifeste des dialogues.
+- \`SRT\` : fichiers de sous-titres par voix et langue.
-1. Ouvrir "/editor".
-2. Sélectionner un objet dans la vue 3D.
-3. Choisir un mode de transformation : translation, rotation ou scale.
-4. Déplacer la gizmo de transformation.
-5. Utiliser undo ou redo si nécessaire.
-6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
+## Carte et transforms
-## Contrôles
+1. Ouvre \`/editor\`.
+2. Clique un objet pour le sélectionner.
+3. Choisis \`Translate\`, \`Rotate\` ou \`Scale\`.
+4. Déplace la gizmo dans la vue 3D.
+5. Vérifie le bloc \`JSON\` si tu veux contrôler les valeurs exactes.
+6. Utilise \`Undo\` ou \`Redo\` si besoin.
+7. Utilise \`Export JSON\` ou \`Save to server\`.
+
+Contrôles utiles :
| Action | Input |
| --- | --- |
-| Sélectionner un objet | Clic sur l'objet |
-| Désélectionner | "Esc" ou clic dans le vide |
-| Mode translation | "T" |
-| Mode rotation | "R" |
-| Mode scale | "S" |
-| Undo | "Ctrl+Z" |
-| Redo | "Ctrl+Y" |
-| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
-| Monter / descendre | "Space", "Shift" |
+| Sélectionner | Clic objet |
+| Désélectionner | \`Esc\` ou clic vide |
+| Verrouiller la sélection | bouton lock |
+| Vider la sélection | bouton \`X\` |
+| Translate | \`T\` |
+| Rotate | \`R\` |
+| Scale | \`S\` |
+| Undo / redo | \`Ctrl+Z\` / \`Ctrl+Y\` |
+| Déplacement vue verrouillée | \`WASD\`, \`ZQSD\`, flèches |
-## Actions fichier
+Quand la sélection est verrouillée, cliquer un autre objet, cliquer dans le vide ou appuyer sur \`Esc\` ne change pas la sélection. Le bouton \`X\` reste le moyen volontaire de la vider.
-### Export JSON
+## Inspecteur JSON
-"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
+Le bloc \`JSON\` affiche le JSON qui sera exporté ou sauvegardé :
-### Save to server
+- sans sélection, il affiche toute la liste de nodes
+- avec une sélection, il affiche les lignes du node sélectionné
-"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
+Cet inspecteur est en lecture seule. Les valeurs changent via la gizmo de transformation.
-Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
+## Sauvegarde
-## Éditer les dialogues et sous-titres
+- \`Export JSON\` télécharge un \`map.json\` local.
+- \`Save to server\` écrit directement \`public/map.json\` via le serveur Vite local.
-Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
+Les sauvegardes serveur sont des helpers de développement, pas des APIs de production.
-### Manifeste dialogues
+## Cinématiques
-Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
+Le groupe \`Cinematics\` édite \`public/cinematics.json\`.
-- \`Reload\` recharge le manifeste depuis le disque.
-- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible.
-- \`Save\` écrit le manifeste via le serveur Vite local.
-- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur.
-- \`Create FR SRT cue\` crée la cue française si elle manque.
-- \`Delete dialogue\` supprime localement l'entrée sélectionnée.
-
-Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé.
-
-Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale.
-
-### Éditeur SRT
-
-1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`.
-2. Choisir une langue : \`FR\` ou \`EN\`.
-3. Modifier le texte SRT directement dans la textarea.
-4. Utiliser la preview audio pour vérifier le dialogue sélectionné.
-5. Utiliser \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster le timing de la cue sélectionnée avec l'audio.
-6. Utiliser \`Save SRT\` en développement local, ou \`Export SRT\` pour télécharger le fichier manuellement.
-
-Chaque fichier SRT appartient à une voix, pas à un dialogue. Les indexes de cue doivent correspondre aux valeurs \`subtitleCueIndex\` référencées par le manifeste de dialogues.
-
-## Valider les assets de dialogue
-
-Utilise \`Validate\` dans le panneau SRT pour vérifier le manifeste et les assets liés.
-
-La validation vérifie :
-
-- \`public/sounds/dialogue/dialogues.json\`
-- les fichiers audio de dialogue référencés
-- les fichiers SRT français
-- les indexes de cue référencés par le manifeste
-
-Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français.
-
-## Éditer les cinématiques
-
-Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`.
-
-Chaque cinématique contient :
+Une cinématique contient :
- un \`id\`
- un \`timecode\` global optionnel
- au moins deux keyframes caméra
-- des dialogue cues optionnelles synchronisées avec la timeline
+- des \`dialogueCues\` optionnelles
-Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`.
+Workflow conseillé :
-Actions disponibles :
+1. Sélectionne une cinématique ou clique \`Add\`.
+2. Donne un \`id\` stable.
+3. Ajoute ou ajuste les keyframes caméra.
+4. Garde les temps de keyframes dans l'ordre croissant.
+5. Ajoute des \`dialogueCues\` si un dialogue doit démarrer pendant la cinématique.
+6. Clique \`Preview cinematic\` pour tester la caméra.
+7. Clique \`Save\`.
-- \`Reload\` recharge le manifeste.
-- \`Add\` crée une cinématique locale avec deux keyframes.
-- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local.
-- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur.
-- \`Add keyframe\` et \`Remove\` modifient le chemin caméra.
-- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés.
-- \`Delete cinematic\` supprime localement la cinématique sélectionnée.
+Les temps de keyframes et de dialogue cues sont relatifs au début de la cinématique.
-Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois.
+## Dialogues
-## Inspecteur JSON
+Le groupe \`Dialogues\` édite \`public/sounds/dialogue/dialogues.json\`.
-Le panneau latéral affiche le JSON brut de la carte :
+Chaque dialogue contient :
-- sans sélection, il affiche toute la liste des nodes
-- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
+- \`id\` : identifiant stable utilisé par les cinématiques et le runtime
+- \`voice\` : \`narrateur\`, \`fermier\` ou \`electricienne\`
+- \`audio\` : chemin MP3 runtime
+- \`subtitleCueIndex\` : numéro de cue dans le SRT de la voix
+- \`timecode\` : déclenchement global optionnel
-Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
+Workflow conseillé pour créer un dialogue :
+
+1. Clique \`Add\`.
+2. Choisis la bonne voix.
+3. Remplace l'\`id\` généré par un ID lisible.
+4. Remplace le chemin audio placeholder par le vrai MP3.
+5. Vérifie le \`subtitleCueIndex\`.
+6. Clique \`Create FR SRT cue\` si la cue manque.
+7. Clique \`Save\`.
+8. Passe dans \`SRT\` pour écrire le texte et régler les timings.
+9. Lance \`Validate\`.
+
+## SRT
+
+Le groupe \`SRT\` édite un fichier de sous-titres à la fois.
+
+1. Choisis une voix.
+2. Choisis \`FR\` ou \`EN\`.
+3. Écris le texte SRT.
+4. Prévisualise l'audio.
+5. Utilise \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster les timings.
+6. Clique \`Save SRT\` ou \`Export SRT\`.
+
+Il y a un fichier SRT par voix et par langue, pas un fichier par dialogue. Les timings SRT sont relatifs au fichier audio du dialogue.
+
+## Validation
+
+\`Validate\` vérifie :
+
+- le manifeste \`dialogues.json\`
+- les fichiers audio référencés
+- les fichiers SRT français
+- les indexes de cues référencés
+- les SRT anglais en warning si manquants
## Limites actuelles
-- L'éditeur modifie uniquement les nodes existants.
-- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
-- La sauvegarde production n'est pas implémentée.
-- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
-- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production.
-- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite.
+- L'éditeur modifie les nodes existants mais ne crée pas encore d'objet de carte.
+- Les sauvegardes serveur sont limitées au développement local.
+- L'éditeur SRT reste textuel, sans waveform.
+- Les modèles manquants sont représentés par des cubes de fallback.
`;
diff --git a/src/index.css b/src/index.css
index ce333ef..d52eddb 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1142,6 +1142,61 @@ canvas {
line-height: 1.45;
}
+.editor-panel-group {
+ border-top: 1px solid rgba(255, 255, 255, 0.09);
+}
+
+.editor-panel-group-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 13px 12px;
+ color: #ffffff;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ list-style: none;
+ text-transform: uppercase;
+ user-select: none;
+}
+
+.editor-panel-group-summary::-webkit-details-marker {
+ display: none;
+}
+
+.editor-panel-group-summary:hover {
+ color: #f2f2f2;
+}
+
+.editor-panel-group-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #777777;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.editor-panel-group-meta svg {
+ transition: transform 160ms ease;
+}
+
+.editor-panel-group[open] .editor-panel-group-meta svg {
+ transform: rotate(180deg);
+}
+
+.editor-panel-group-content > .editor-control-section:first-child,
+.editor-panel-group-content > .editor-json-section:first-child,
+.editor-panel-group-content > .editor-cinematic-manifest-section:first-child,
+.editor-panel-group-content > .editor-dialogue-manifest-section:first-child,
+.editor-panel-group-content > .editor-srt-section:first-child {
+ border-top: 0;
+}
+
.editor-control-section {
padding: 14px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
@@ -1313,7 +1368,8 @@ canvas {
}
.editor-selected-info {
- display: flex;
+ display: grid;
+ grid-template-columns: 17px 1fr auto;
align-items: center;
gap: 11px;
background: #ffffff;
@@ -1323,6 +1379,38 @@ canvas {
color: #050505;
}
+.editor-selected-actions {
+ display: inline-flex;
+ gap: 6px;
+}
+
+.editor-selected-actions button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 27px;
+ height: 27px;
+ padding: 0;
+ color: #050505;
+ background: rgba(0, 0, 0, 0.06);
+ border: 0;
+ border-radius: 9px;
+ cursor: pointer;
+ transition:
+ background 160ms ease,
+ transform 160ms ease;
+}
+
+.editor-selected-actions button:hover {
+ background: rgba(0, 0, 0, 0.12);
+ transform: translateY(-1px);
+}
+
+.editor-selected-actions button[aria-pressed="true"] {
+ color: #ffffff;
+ background: #050505;
+}
+
.editor-selected-info strong,
.editor-selected-info span {
display: block;
diff --git a/src/pages/docs/animation/page.tsx b/src/pages/docs/animation/page.tsx
index fb5404b..fb881fa 100644
--- a/src/pages/docs/animation/page.tsx
+++ b/src/pages/docs/animation/page.tsx
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
);
diff --git a/src/pages/docs/audio/page.tsx b/src/pages/docs/audio/page.tsx
new file mode 100644
index 0000000..2382d2f
--- /dev/null
+++ b/src/pages/docs/audio/page.tsx
@@ -0,0 +1,13 @@
+import audio from "../../../../docs/technical/audio.md?raw";
+import { DocsDocument } from "@/components/docs/DocsDocument";
+
+export function DocsAudioPage(): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/pages/docs/editor/page.tsx b/src/pages/docs/editor/page.tsx
index d44f7cd..c993678 100644
--- a/src/pages/docs/editor/page.tsx
+++ b/src/pages/docs/editor/page.tsx
@@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element {
);
diff --git a/src/pages/docs/features/page.tsx b/src/pages/docs/features/page.tsx
index 8010b5f..a075d1f 100644
--- a/src/pages/docs/features/page.tsx
+++ b/src/pages/docs/features/page.tsx
@@ -7,7 +7,7 @@ export function DocsFeaturesPage(): React.JSX.Element {
);
diff --git a/src/pages/docs/hand-tracking/page.tsx b/src/pages/docs/hand-tracking/page.tsx
index 26e7176..0d2e1a3 100644
--- a/src/pages/docs/hand-tracking/page.tsx
+++ b/src/pages/docs/hand-tracking/page.tsx
@@ -6,7 +6,7 @@ export function DocsHandTrackingPage(): React.JSX.Element {
);
diff --git a/src/pages/docs/main-feature/page.tsx b/src/pages/docs/main-feature/page.tsx
index db324be..eacf31a 100644
--- a/src/pages/docs/main-feature/page.tsx
+++ b/src/pages/docs/main-feature/page.tsx
@@ -6,7 +6,7 @@ export function DocsMainFeaturePage(): React.JSX.Element {
);
diff --git a/src/pages/docs/zustand/page.tsx b/src/pages/docs/zustand/page.tsx
index 529be3a..45d9ea9 100644
--- a/src/pages/docs/zustand/page.tsx
+++ b/src/pages/docs/zustand/page.tsx
@@ -7,7 +7,7 @@ export function DocsZustandPage(): React.JSX.Element {
);
diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx
index e57d176..0f8c5b1 100644
--- a/src/pages/editor/page.tsx
+++ b/src/pages/editor/page.tsx
@@ -67,6 +67,7 @@ export function EditorPage(): React.JSX.Element {
const [transformMode, setTransformMode] =
useState("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
+ const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [sceneLoadingState, setSceneLoadingState] = useState(
{
...INITIAL_SCENE_LOADING_STATE,
@@ -112,6 +113,14 @@ export function EditorPage(): React.JSX.Element {
setSelectedNodeIndex(index);
}, []);
+ const handleClearSelection = useCallback(() => {
+ setSelectedNodeIndex(null);
+ }, []);
+
+ const handleSelectionLockToggle = useCallback(() => {
+ setIsSelectionLocked((locked) => !locked);
+ }, []);
+
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
@@ -246,6 +255,7 @@ export function EditorPage(): React.JSX.Element {
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
+ isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
@@ -276,6 +286,9 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
+ isSelectionLocked={isSelectionLocked}
+ onSelectionLockToggle={handleSelectionLockToggle}
+ onClearSelection={handleClearSelection}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
diff --git a/src/router.tsx b/src/router.tsx
index 7be6634..7ccf38f 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -8,6 +8,7 @@ import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page";
import {
DocsAnimationRoute,
+ DocsAudioRoute,
DocsArchitectureRoute,
DocsEditorRoute,
DocsFeaturesRoute,
@@ -47,6 +48,7 @@ const docsChildRoutes = [
{ path: "architecture", component: DocsArchitectureRoute },
{ path: "target-architecture", component: DocsTargetArchitectureRoute },
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
+ { path: "audio", component: DocsAudioRoute },
{ path: "hand-tracking", component: DocsHandTrackingRoute },
{ path: "zustand", component: DocsZustandRoute },
{ path: "features", component: DocsFeaturesRoute },
diff --git a/src/routes/DocsRoute.tsx b/src/routes/DocsRoute.tsx
index a9e42a7..ec5006f 100644
--- a/src/routes/DocsRoute.tsx
+++ b/src/routes/DocsRoute.tsx
@@ -47,6 +47,10 @@ const LazyDocsTechnicalEditorPage = lazyNamed(
() => import("@/pages/docs/technical-editor/page"),
"DocsTechnicalEditorPage",
);
+const LazyDocsAudioPage = lazyNamed(
+ () => import("@/pages/docs/audio/page"),
+ "DocsAudioPage",
+);
const LazyDocsHandTrackingPage = lazyNamed(
() => import("@/pages/docs/hand-tracking/page"),
"DocsHandTrackingPage",
@@ -81,6 +85,7 @@ export const DocsTargetArchitectureRoute = createDocsRoute(
export const DocsTechnicalEditorRoute = createDocsRoute(
LazyDocsTechnicalEditorPage,
);
+export const DocsAudioRoute = createDocsRoute(LazyDocsAudioPage);
export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage);
export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage);
export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);