diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index b1d2ec9..a727a59 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ diff --git a/mixer_panel_backup_20260729_0941.patch b/mixer_panel_backup_20260729_0941.patch new file mode 100644 index 0000000..3f985b4 --- /dev/null +++ b/mixer_panel_backup_20260729_0941.patch @@ -0,0 +1,564 @@ +From 46d3778a5d5305ba625318433b7f98c1e4dac932 Mon Sep 17 00:00:00 2001 +From: 3dtours +Date: Wed, 29 Jul 2026 09:11:54 +0700 +Subject: [PATCH] =?UTF-8?q?FIX:=20chi=E1=BB=81u=20cao=20c=E1=BB=A7a=20trac?= + =?UTF-8?q?k=20thu=20nh=E1=BB=8F=20theo=20chi=E1=BB=81u=20cao=20c=E1=BB=A7?= + =?UTF-8?q?a=20Mixer=20Panel?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +--- + .kilo/plans/mixer-equalizer-plan.md | 293 ++++++++++++++++++ + .../js/services/audioMixerGraphManager.js | 20 +- + app/storage/sonicforge.db | Bin 77824 -> 77824 bytes + app/templates/index.html | 2 +- + 4 files changed, 310 insertions(+), 5 deletions(-) + create mode 100644 .kilo/plans/mixer-equalizer-plan.md + +diff --git a/.kilo/plans/mixer-equalizer-plan.md b/.kilo/plans/mixer-equalizer-plan.md +new file mode 100644 +index 0000000..2fbf79e +--- /dev/null ++++ b/.kilo/plans/mixer-equalizer-plan.md +@@ -0,0 +1,293 @@ ++# Mixer Console & Equalizer Implementation Plan ++ ++## Goal ++Implement a docked Mixer Console panel (similar to REAPER's MCP) at the bottom of the DAW, featuring per-track channel strips with volume fader, pan knob, Mute/Solo, VU meter, and FX controls. Support MAIN SESSION and SECTION-TAB contexts with hierarchical audio routing. ++ ++--- ++ ++## Task 1: Create `AudioMixerGraphManager` ++ ++**File:** `app/static/js/services/audioMixerGraphManager.js` (new) ++ ++Class managing all Web Audio nodes with a proper Master Bus signal chain: ++ ++``` ++source → trackGain → faderGain → panNode → analyserNode → masterGain → masterPan → masterAnalyser → destination ++``` ++ ++### Responsibilities ++ ++- **Singleton pattern** — created once with `audioCtx`, stored on `window.AudioMixerGraphManager`. ++- **Master Bus** — `masterGain → masterPan → masterAnalyser → audioCtx.destination`. ++- **Track Node Map** — `Map`. ++- **Section Bus Map** — `Map`. ++- `createMainTrackNodes(track)` — creates node chain, connects analyser → masterGain. Stores in `trackNodesMap`. Calls `updateTrackVolumePan()`. ++- `createSectionSubMixBus(sectionId, hostTrackId)` — creates subMixGain→subMixPan, pipes to host track's `inputGain`. Falls back to masterGain if host track not found. ++- `createSectionTrackNodes(sectionId, secTrack)` — creates nodes for internal section tracks, connects analyser → section's `subMixGain`. ++- `updateTrackVolumePan(trackId, volumeDb, panValue, isMuted)` — updates gain/pan in real-time. `gain = isMuted ? 0 : Math.pow(10, volumeDb / 20.0)`. ++- `getAnalyserData(trackId)` — returns `analyserNode` frequency/time-domain data for VU meter rendering. ++- `removeTrackNodes(trackId)` — disconnects and removes from map. ++ ++### Signal Equations ++- `linearGain = isMuted ? 0 : Math.pow(10, volumeDb / 20.0)` — dB to linear scale. ++- `panValue = track.pan / 100` — existing -100..+100 range maps to -1.0..+1.0 `StereoPanner.pan`. ++- Use `setTargetAtTime(linearGain, ctx.currentTime, 0.01)` for smooth transitions. ++ ++--- ++ ++## Task 2: Refactor `app.jsx` Audio Node Creation ++ ++**File:** `app/static/js/app.jsx` — modify existing audio routing functions. ++ ++### 2a. Replace `getOrCreateTrackNode()` with `AudioMixerGraphManager` ++ ++Currently lines 10314-10343 create a local `{ gainNode, pannerNode }` per track and connect `pannerNode → context.destination`. Replace this with: ++ ++```js ++function getOrCreateTrackNode(track, context) { ++ if (!window.AudioMixerGraphManager) return null; ++ const mgr = window.AudioMixerGraphManager; ++ const bundle = mgr.trackNodesMap.get(track.id); ++ if (bundle) return bundle.faderGain; ++ // Create nodes via AudioMixerGraphManager ++ mgr.createMainTrackNodes(track); ++ return mgr.trackNodesMap.get(track.id).faderGain; ++} ++``` ++ ++### 2b. Section Sub-Track Audio Routing ++ ++Replace `getOrCreateSubTrackNode()` (lines 10344-10359) to: ++1. Create section sub-mix bus via `AudioMixerGraphManager.createSectionSubMixBus()` (first time only, lazy init). ++2. Create section internal track nodes via `AudioMixerGraphManager.createSectionTrackNodes()`. ++3. Return `faderGain` node for audio connection. ++ ++### 2c. SoundFont MIDI Audio Routing Fix ++ ++**Critical:** FluidSynth renders directly to `audioCtx.destination` via ScriptProcessorNode. SoundFont player (`soundfontPlayer.js` lines 27-49) has its own `_gainNode` connected to destination. This bypasses the track's gain/panner nodes. ++ ++**Fix in `soundfontPlayer.js`:** ++- Modify the first `_gainNode` creation so that instead of `_gainNode.connect(ctx.destination)`, it connects to a pass-through that can be dynamically routed to any destination node. ++- OR: In `startTrackPlayback()` (app.jsx ~line 10382), after calling `window.SonicSF.playNote(...)`, re-route the SF output by calling a new method `window.SonicSF.setOutputNode(destinationNode)` that disconnects the internal `_gainNode` from `destination` and reconnects it to the track's `faderGain`. ++ ++**Simpler approach:** Add `setOutputNode(destinationNode)` to `soundfontPlayer.js`: ++```js ++function setOutputNode(newDest) { ++ _gainNode.disconnect(); ++ _gainNode.connect(newDest || ctx.destination); ++} ++``` ++ ++Call this from `getOrCreateTrackNode()` after creating/retrieving the track's `faderGain`, passing `trackFaderGain` as the destination. ++ ++--- ++ ++## Task 3: Mixer Console State & Toggle ++ ++**File:** `app/static/js/app.jsx` ++ ++### 3a. Add Mixer Visibility State ++ ++```js ++const [showMixer, setShowMixer] = useState(false); ++``` ++ ++### 3b. Mixer Toggle Button ++ ++Add a toolbar button (e.g., in the TCP header near the "Add Track" button, or in a bottom toolbar). Icon: "mixer" (sliders icon). Toggles `showMixer`. ++ ++### 3c. Determine Active Context ++ ++When `activeTab === 'main'`, mixer shows MAIN SESSION tracks. When a SECTION-TAB is active, mixer shows the section's internal tracks (from `sessionTabs.find(s => s.id === activeTab).tracks`). ++ ++### 3d. Pass to MixerConsole ++ ++``` ++mixerContext = { isSectionTab, currentTracks, currentTitle, activeTabContext } ++``` ++ ++--- ++ ++## Task 4: Create MixerConsole React Component ++ ++**File:** `app/static/js/components/MixerConsole.jsx` (new) ++ ++Docked at the bottom of the timeline (inside the `bottom` dock panel or a fixed container at bottom of the `flex-1 flex flex-col overflow-hidden` area, below the timeline canvas). ++ ++### 4a. Component Structure ++ ++``` ++MixerConsole (flex-col h-48 border-t border-zinc-700 bg-slate-900) ++├── MixerHeader (flex, shows "MAIN SESSION" or "SECTION TAB" label + close button) ++└── MixerStripsWrapper (flex-1 flex overflow-x-auto p-2 gap-1.5) ++ ├── MasterChannelStrip (w-28, fixed, left-aligned) ++ ├── Divider (w-[1px] bg-zinc-700) ++ └── Track strips (horizontal scroll, each TrackChannelStrip w-24) ++``` ++ ++### 4b. MasterChannelStrip ++ ++Props: `isSectionBus, mixerGraphMgr` ++ ++- Shows "MASTER" or "SEC BUS" header. ++- VU Meter canvas element (reads `masterAnalyser` frequency data, draws RMS/Peak bar). ++- Peak/rms numeric readout. ++- Mono toggle button. ++- "MAIN OUT" / "SUB-MIX" footer. ++ ++### 4c. TrackChannelStrip ++ ++Props: `track, index, mixerGraphMgr` ++ ++Local state: `volumeDb, pan, isMuted, isSoloed` — initialized from `track` props. ++ ++Renders: ++1. **Pan knob** — `` vertical/horizontal slider + numeric label (C / Lxx / Rxx). ++2. **dB Readout** — `volumeDb <= -60 ? "-inf" : "[+/-]N.NdB"`. ++3. **Mute / Solo buttons** — `[M]` orange when muted, `[S]` yellow when soloed. Mute dispatches `updateTrackVolumePan(trackId, vol, pan, newMute)`. Solo dispatches `toggleTrackSoloEvaluate(trackId)`. ++4. **Fader + VU Meter** — vertical fader `` with CSS `writing-mode: vertical-lr`. VU meter bar (vertical `div` with gradient, height calculated from analyser RMS data). RequestAnimationFrame loop reads `analyserNode.getByteTimeDomainData()` and updates VU height. ++5. **FX / Power buttons** — `[FX]` stub that shows toast "FX panel coming soon". `[⑈]` bypass toggle (stub). ++6. **Footer** — `{index}. {track.name}` + `{track.type}`. ++ ++### 4e. VU Meter Animation ++ ++Use `requestAnimationFrame` loop inside `TrackChannelStrip`: ++```js ++useEffect(() => { ++ const analyser = mixerGraphMgr?.trackNodesMap.get(track.id)?.analyserNode; ++ if (!analyser) return; ++ analyser.fftSize = 128; ++ const bufferLength = analyser.frequencyBinCount; ++ const dataArray = new Uint8Array(bufferLength); ++ let rafId; ++ const draw = () => { ++ analyser.getByteTimeDomainData(dataArray); ++ const rms = Math.sqrt(dataArray.reduce((sum, v) => sum + ((v - 128) / 128) ** 2, 0) / bufferLength); ++ setMeterLevel(Math.min(1, rms * 2)); ++ rafId = requestAnimationFrame(draw); ++ }; ++ rafId = requestAnimationFrame(draw); ++ return () => cancelAnimationFrame(rafId); ++}, [track.id, mixerGraphMgr]); ++``` ++ ++--- ++ ++## Task 5: Add Mixer Toggle Button (TCP Header) ++ ++**File:** `app/static/js/app.jsx` — TCP header area (line 16648-16656). ++ ++Add a mixer toggle button next to the "Add Track" button: ++ ++```jsx ++/*#__PURE__*/React.createElement("button", { ++ onClick: () => setShowMixer(!showMixer), ++ className: "px-2 py-1 bg-indigo-700 hover:bg-indigo-600 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition" ++}, /*#__PURE__*/React.createElement("span", { ++ className: "inline-flex items-center shrink-0" ++}, /*#__PURE__*/React.createElement("i", { ++ "data-lucide": "sliders-horizontal", ++ className: "w-3.5 h-3.5" ++})), " Mixer") ++``` ++ ++## Task 6: Add F7 Keyboard Shortcut for Mixer Toggle ++ ++**File:** `app/static/js/app.jsx` — modify the global keyboard handler (lines 8037-8335). ++ ++The keydown handler at line 8334 (`window.addEventListener('keydown', handler, { capture: true })`) already handles hotkeys like Space (play/pause) and function keys for sub-tab operations. ++ ++Add an `F7` check inside the handler, **before** the sub-tab-only block at line 8054 (so it works in both MAIN SESSION and sub-tabs): ++ ++```js ++// F7: Toggle Mixer Console ++if (e.key === 'F7') { ++ e.preventDefault(); ++ setShowMixerRef.current(prev => !prev); ++ return; ++} ++``` ++ ++Use a ref (`showMixerRef`) to access the current `showMixer` state without needing it as a dependency of the effect. Create the ref and keep it synced in the component body: ++ ++```js ++const showMixerRef = useRef(showMixer); ++showMixerRef.current = showMixer; ++``` ++ ++Keep `setShowMixerRef` as a ref to `setShowMixer` to call it from the event listener without stale closures. ++ ++This avoids adding `showMixer` as a dependency to the keyboard effect, which would cause unnecessary listener re-registrations. ++ ++--- ++ ++## Task 7: Integrate MixerConsole into App Layout ++ ++**File:** `app/static/js/app.jsx` ++ ++### 5a. Placement ++ ++After the timeline track list (the `div` containing `activeTracks.map(...)` and the add-track row), and before the transport bar, insert: ++ ++```jsx ++showMixer && React.createElement(MixerConsole, { ++ key: 'mixer-console', ++ mixerContext: { isSectionTab, currentTracks, currentTitle, activeTabContext }, ++ mixerGraphMgr: window.AudioMixerGraphManager, ++ trackActions: { updateTrackVolumeDb, updateTrackPan, toggleTrackMute, toggleTrackSoloEvaluate } ++}) ++``` ++ ++### 5b. Panel System Integration ++ ++Alternatively, register "mixer" as a panel that can be docked to the `bottom` dock panel. The panel content renders `MixerConsole`. This reuses the existing `dockPanels` / `renderDock` system — add a menu item or button that calls `addToDock('bottom', 'mixer')`. ++ ++Simpler approach: fixed position below the timeline with a toggle button. ++ ++--- ++ ++## Task 8: Section-Tab Context Switching ++ ++**File:** `app/static/js/app.jsx` ++ ++When `activeTab` changes to a SECTION-TAB (`sessionTabs.some(s => s.id === activeTab)`): ++- MixerConsole's `currentTracks` switches to the section's internal tracks. ++- The Master strip label changes from "MASTER" to "SEC BUS". ++- Header shows "MIXER: SECTION TAB ({sectionName})". ++- Audio routing uses `createSectionSubMixBus` + `createSectionTrackNodes`. ++ ++When switching back to `activeTab === 'main'`: ++- MixerConsole reverts to MAIN SESSION tracks and Master strip. ++ ++This logic lives inside a `useEffect` that watches `activeTab` and re-renders the MixerConsole with the correct context. ++ ++--- ++ ++## Task 9: Mixer Resize & Toggle Persistence ++ ++- Add a top drag handle on the MixerConsole (`h-1 cursor-ns-resize hover:bg-cyan-500/50`) to adjust height (range: `h-32` to `h-80`). Store height in a ref. ++- Store `showMixer` and mixer height in `localStorage` for persistence. ++- Close button (`[X]`) on the Mixer header sets `showMixer = false`. ++ ++--- ++ ++## Validation Plan (Additions for F7 + Toggle Button) ++ ++1. [ ] **Button toggle** — Click "Mixer" button in TCP header → MixerConsole appears. Click again → hides. ++2. [ ] **F7 hotkey** — Press F7 → MixerConsole toggles. Works in both MAIN SESSION and SECTION-TAB. Does NOT interfere with input/textarea focus. ++3. [ ] **No stale closure** — Repeated F7 presses reliably toggle state (setShowMixerRef.current used, not a stale function). ++ ++## Extended Validation Plan ++ ++1. **Toggle mixer** — Click the Mixer button → MixerConsole appears at bottom. Click again → hides. ++2. **MAIN SESSION mode** — Shows all tracks in the session. Master strip on left. ++3. **Volume fader** — Drag fader → track volume changes audibly in real-time. Numeric dB readout updates. ++4. **Pan knob** — Drag pan → stereo position changes. Label shows C / Lxx / Rxx. ++5. **Mute** — Click [M] → track mutes, button turns orange. Click again → unmutes. ++6. **Solo** — Click [S] on track 1 → track 1 plays, all other tracks silence. Click again → all tracks resume. ++7. **VU Meter** — During playback, meter bars animate with signal level (green/yellow/red gradient). ++8. **SECTION-TAB** — Open a section → MixerConsole switches to section tracks, Master strip shows "SEC BUS". ++9. **Section sub-mix volume** — Adjust fader on the SECTION host track → entire section volume changes. ++10. **MIDI audio routing** — SoundFont MIDI notes play through the track's fader/panner, not bypassing. ++11. **Resize** — Drag the resize handle on top of MixerConsole → height changes. ++12. **Persistence** — Reload page → mixer state restored from localStorage. +diff --git a/app/static/js/services/audioMixerGraphManager.js b/app/static/js/services/audioMixerGraphManager.js +index 93c530b..60c39c2 100644 +--- a/app/static/js/services/audioMixerGraphManager.js ++++ b/app/static/js/services/audioMixerGraphManager.js +@@ -4,10 +4,22 @@ + function AudioMixerGraphManager(audioCtx) { + this.audioCtx = audioCtx; + this.trackNodesMap = new Map(); +- this.sectionBusMap = new Map(); +- +- // Master bus chain: masterGain -> masterPan -> masterAnalyser -> destination +- this.masterGain = audioCtx.createGain(); ++ this.sectionBusMap = new Map(); ++ this.trackFxBypassGains = new Map(); ++ ++ // Master bus chain: masterGain -> masterPan -> masterBypassGain -> masterAnalyser -> destination ++ this.masterGain = audioCtx.createGain(); ++ this.masterGain.gain.setValueAtTime(1, audioCtx.currentTime); ++ this.masterPan = audioCtx.createStereoPanner(); ++ this.masterPan.pan.setValueAtTime(0, audioCtx.currentTime); ++ this.masterBypassGain = audioCtx.createGain(); ++ this.masterBypassGain.gain.setValueAtTime(1, audioCtx.currentTime); ++ this.masterAnalyser = audioCtx.createAnalyser(); ++ this.masterAnalyser.fftSize = 128; ++ this.masterGain.connect(this.masterPan); ++ this.masterPan.connect(this.masterBypassGain); ++ this.masterBypassGain.connect(this.masterAnalyser); ++ this.masterAnalyser.connect(audioCtx.destination); + this.masterGain.gain.setValueAtTime(1, audioCtx.currentTime); + this.masterPan = audioCtx.createStereoPanner(); + this.masterPan.pan.setValueAtTime(0, audioCtx.currentTime); +diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db +index b1d2ec94d1a0c8be003360e1202979abb9d62c6c..0ffa5a826df5e7beabb1bee853e683414f554125 100644 +GIT binary patch +delta 1068 +zcmV+{1k?L~-~@o+1dtmIp8x;=6951J5dZ)H29YQ?0iUs85FY^vlW`wHKsjPDIWspl +zH)J(pH#0ObH8f*2V_{=rW@b1zGG;b2HaIb6GB{#mH8eM3FfleWFl1(AWH2#dGdX5v +zHDfX{FthU?5nu`f50U^6k`L|=z_Sq`gb$Ozj|n9-F*!6aI5;^iF*rG8Ei`0iG%YwW +zV=^sdVlZQ2VKp=~VK8L_v%rr;FtY{V{0|Hb3zPr?2gCpjEeFK0fw%`*hfMN0La&>KGZggL8a&KpHVQnHhZgp&IEFx)cb98cbZDnqBUv6P-WgtK*$v?Zgp&IEFyDnb#7#4Z*Fv7X=EZgZgp&IEFyDnb#7#4Z*Fv7VqtD;B06q$ +zY;2Pd)D=2zb!==bB58DGZF3?zTU~uDdm?FMB03^6A}k_qVQpn1IwDkZVPk6`Fft-6 +zB6N9hWgN0La&>KGZggL8a&KpHVQnHhZgp&IEFx)c +zb98cbZDnqBUv6P-WgtK*$v?Zgp&IEFyDnb#7#4Z*Fv7X=EZgZgp&IEFyDn +zb#7#4Z*Fv7VqtD;B06q$Y;2Pd)D;eHb!==bB58DGZF3?zTa(bwCJqBsLE4&AiE|l4 +z0001)k%5&7hfo3uUsXSiJF^?nodFkAa$#d@ATTi^EFyGyaAhJoB0*I|Nl%lx$3iu3 +zb!==bB57`ObaHiVWo~p|aB^>Fa$#*EI&O7rY%C&aZgX^Ub!}yCbYE^^ZDk@lZgp&I +zlR(H7E^c*fY%C&kZ*^{DW^ZnEUuk3_I&O7rY%C&kZ*^{DW^ZnEUt(cyYa%*sb!=>t +z5Y!bqZgp&IEFx)iWo>gJI$K?REPEnpWFk5uG9oM@ZeeX@B03^ea$#d@ATTl_EFyGy +zaAhJoB0*I|Nl%lx$3iu3b!==bB57`ObaHiVWo~p|aB^>Fa$#*EI&O7rY%C&aZgX^U +zb!}yCbYE^^ZDk@lZgp&IlR(H7E^c*fY%C&kZ*^{DW^ZnEUuk3_I&O7rY%C&kZ*^{D +zW^ZnEUt(cyYa%*sb!=>t5Y!b8Zgp&IEFx)iWo>gJI$M*_&n5~3S3%mESWSxm%(DUD +m1|Svy1poj55C8xG6951J5&!@I4*&oF4YL8@1|YK_aNZB((q(7> + +delta 7203 +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 +zG&eOcFflnXUo$WwEFy4ebYo~DIyN>eB6DV>{ +zVt-{}baNs)FfKAREFyMgY;R*}ba^5=FfKJLB5+}DB04aAEPEnpWFk5uZf|sDUvgz( +za&liWH#jviG&eOcFflnXUo$ZxEFy4ebYo~DIyW&aB6D@raIxsFYEFy4WZX!A`eO-NhU41Nj +zB57nIIwCS6EFx}UZDk@lB0_Rub8{d~Z**y9A}k_wd2nSSIwDO;L`jo=*+LB>b7o&@ +zY;1LNX>V>KlQ7;EA~85DB57`ObaHiVWo~p|ZeeX@B03^dd2V!QAVP9sb8{k-Vcr!! +zB6DwbZe(U}Zge6nB6DwbZe(U}ZggL1WFk5uXJu|>a$#(Bb7gX0XLBMfB6DwbZe(U} +zZggK_VQy<8Ixv$#+Z9qVI4mM_d2V!QUuAA*X>MgAI(s5?d2nSSIwEs#b#7#4Z*FuV +zEFy4hb!TaAUuk3_IwEssUukS?b#rNNZXzrqb8mHSWM*$}bYEg&ZfhbsFq1*s6@M`} +zEFyDnb#7#4Z*Fv7X=EZgB57=Fb#rNNZX$gwB58DGZF3?zTYDmDWFk5uX>?_6Utwuq +zF*i6hGBh_dHa0aiG+#1gZfiVRCaIGBqtTG9WD=LULhqbAKRBZ**y9A}k_wd2nSSIwDO;L`h#sR7Fi9EFyDs +zVRCd|VqtP3Ixs9EWOZ_3bZKvHUt(c$b0Ru8EFxoUX>eb2bYXIIUvFk+b7gd2VqtP~ +zB04ZEB6Dwba${v*WMOn+B075_bZ>NFY+qt^W@cq_Ut(c$b0Ru8EFx}ibbn=YB05`p +zB57nIIwEdwbY)**XaA|a7Xd*f`G%O->bYXIIUt(op +zbRs%3EFxrea$$67Z*E^=Wnpx4B04fGB6ekLZ)0h6c_KP6E;B45aA9sDIxu}Kdm?FM +zB03^&Z**l}VQF76H#jviG=DcVHa0aiG+!|yEFy4ebYo~DIyN^fB6D@raIxsFXEFy4WZX!A`eJpz-X=EZg +zB5rSVWnW=wUokg0H8M0eG&VLhH8fu`A}k_sX>?;~B04rSEFyDsVSjRTUt(opbRs%2 +zI4mM$b#h^JX>V>{Vr5};b0Ru2EFyMgY;R*}ba^5=FfKDJB5+}DB04aAEPEnpWFk5u +zZf|sDUtwuqF*i6hGBh_dHa0aiG+#3!EFy4ebYo~DIyN^fB6DJWTT^slZZTaTEmK1{I8Y*! +zVcr!$B6DwbZe(U}Zge6nB6DwbZe(U}ZggL1WFk5uXJu|>a$#(Bb7gX0XLBMfB6Dwb +zZe(U}ZggK_VQy<8Ix#XhlR?`RQ!p$db9rubXkTS+XK8L_B075_ba`-PB03^-Z*^{D +zW^ZnEA}k_sY;|X8ZeM9+B03^-W?yq@Zf0+8Gd3bDB6DwbZe(U}ZggK_VQy<8Ix#Xh +zlR?`Re=sZ}b8mHSWM*$}bYE#?B03^-X>Mk3ZZkF_eJmnrbY*RGB05`pB57nIIwEOw +zWo=(!XV>{Vr5};b0Ru1EFyMgY;R*}ba^5=FfKJLB5+}DB04aA +zEPEnpWFk5uZf|sDUtwuqF*i6hGBh_de>OHXH8fu_A}k_sX>?;~B04iREFyDsVRCd| +zVr5};B04xMB4l-PVRUJ4ZeL<$VRUmMIx#FFc4cgDV`+4GB04ZGH7p`Wo&O_X>@raIxsFZEFy4WZX!A`eJpz- +zX=EZgB5rSVWnW=wUokg0H8M0eG&VLhH8fu{A}k_sX>?;~B04iREFyDsVRCd|Vr5}; +zB04fOEFxrea$$67Z*E^=Wnpx49wIt1EFyMgY;R*}ba^5=FfKJLB5+}DB04aAU44C% +z(BCE$9%@0_nl0}B-C_*@000325&!`bv4IEy3B6+i313w|`C+p*+?@d&Q*?4^ZfA2K +zOl4gJI$L`pX=EZgB58DGZC_z&Uokg0H8M0eG&VLhG%;UcHZ^HAA}k_qVQpn1 +zIwD1IX=5Nya${&^b98cHY#>Q)bY*gEb!25ALSb@qATl*AGcq78AX9X5X>NaKb0AD* +zXJK@2A}k_wd2nSSIwDO;L`h#sR7Fi9EFyDsVRCd|VqtP3Ixs9EWOZ_3bZKvHUt(c$ +zb0Ru8EFxoUX>eb2bYXIIUvFk+b7gd2VqtP~B04ZEB6Dwba${v*WMOn+B075_bZ>NF +zY+qt^W@cq_Ut(c$b0Ru8EFynyZ**mIB05`pB57nIIwEdwbY)**XaA|a7Xd*f_GAtr@raIxsFYEFy4WZX!A`eJpz-X=EZgB5rSVWnW=wUokg0H8M0eG&VLhG%;T? +zA}k_sX>?;~B04oUEFyn%bYXIIUt(opbRs%2HY_4!b#h^JX>V>{Vr5};b0Ru8EFyMg +zY;R*}ba^5=FfKGKB5+}DB04aAEPEnpWFk5uZf|sDUtwuqF*i6hGBh_dHa0ahF<&zx +zEFy4ebYo~DIyE^gB6DTA_VRC6Mn8A}k_wd2nSSIwDO; +zL`fnnB6e?Vb#0S|**Y5{b7o(2bY*F7cVT&7V{dL_WpZ?1aA{#~Zz7X0-WDA&EFx)c +zb98cbZDnqBUv6P-Wgb#7#4Z*FuVEFyDnb#7#4Z*Fv7 +zX=EZgB6DNC6d0%61ZewL~bYF03VQz0CEFyDnb#7#4Z*Fv7VqtD;B04aWLE9By +zFf1Z-d2V!QUuAA*X>MgAI(s5?d2nSSIwEs#b#7#4Z*FuVEFy4hb!TaAUuk3_IwEss +zUvqS2X>NC6d0%61ZewL~bYF03VQz0CEFyDnb#7#4Z*Fv7VqtD;B04aWLE9C7Ff1Z- +zZ*^{DW^ZnEUuk3_IwEs)Wod4AVR>I;Z*F5{a&%vCX<=?}B7H0(X>?_6b0Ruhdm?FM +zB03^zbY*Q{a%Ew1a$hkwI5jdfH#IOYF*z|`WNB+|WFjmgZeeX@B03^lK}lU8L2__q +zXJ=_|AXZ^=X<>9}Z*CxOW*|j>aA{*8Q*?4^ZfA2KEg*Jbaw04uba`-PB03^XNkmCs +zNmNBmA}k_vbYXIIUt(c$B04ZEB4l-PVRUJ4ZeL@raIxsFaEFy4WZX!A`eJpz-X=EZg +zB5rSVWnXe-VRCX`F*i6hGBh_eFfcJWF<&tvEFy4ebYo~DIyE&cB6DV>{Vr5};b0Ru0E;2PNB6ekLZ)0h6c_KQ0FfKJLB5+}DB04aA +zEPEnpWFk5uZf|sDUvgz(a&liWH#jviG&eOcFflnXUos*rB5-MRV`w5eH90IIb97;H +zbYEg+VRRxoFfKJLB4l-PVRUJ4ZeL<$VRUmMIxsFWH7p`_Wo&O_X>@raIxsFZEFy4W +zZX!A`eJpz-X=EaQIwEdwbY)+1Wnpr1Uokg0H8M0eH83zSIWb=|A}k_sX>?;~B04rS +zEFyDsVRCd|Vr5};B04ZGH#ICGWOZ_3bZKvHUt(opbaNs)FfKAREFyMgY;R*}ba^5= +zFfKGKB5+}DB04aAEPEnpWFk5uZf|sDUvgz(a&liWH#jwaGBh_eFfcJWF<&$yEFy4e +zbYo~DIyEvZB6DV>{Vr5};b0Ru0E;2PN +zB6ekLZ)0h6c_KP6E;TG7aA9sDIxu}Kdm?FMB03^&Z**l}a%Ew1a$hkwI5jdfH#IOY +zF*z|`HXV`bYXIIUt(opbRs%`F)lSMB4l-PVRUJ4ZeL<$VRUmM +zIxsFWH7p`_Wo&O_X>@raIxsFZEFy4WZX!A`eJpz-X=EZgB5rSVWnXe-VRCX`F*i6h +zGBh_eFfcJWF<&<#EFy4ebYo~DIyN*cB6DV>{ +zVr5}}baNs)FfKAREFyMgY;R*}ba^5=FfKGKB5+}DB04aAEPEnpWFk5uZf|sDUvgz( +za&liWH#jviG&eOcFflnXUpOKxB5-MRV`w5eH8m_Eb97;HbYEg+VRRxoI4mM$b#h^J +zX>V>{Vr5};b0Ru0E;2PNB6ekLZ)0h6c_KQ0FfKMMB5+}DB04aAEPEnpWFk5uZf|sD +zUvgz(a&liWH#jviG&eOcFflnXUpXQyB5-MRV`w5eH90IIb97;HbYEg+VRRxoI4&|Z +zEFxrea$$67Z*E^=Wnpx4B04ZGGBqqBc4cgDV`+4GB04ZGH7p`@raIxsFZEFy4WZX!A` +zeJpz-X=EZgB5rSVWnXe-VRCX`F*i7WH8M0eH83zSIWb=`F(ND?aA|a7Xd*f`H!LD^ +zbYXIIUt(opbRs%9E;ltSB4l-PVRUJ4ZeL<$VRUmMIxsFWH7p`_Wo&O_X>@raIxsFY +zEFy4WZX!A`eJpz-X=EZgB5rSVWnXe-VRCX`F*i6hGBh_eFfcJWF<&tV`bYXIIUt(o{VRRxoIW96aEFxrea$$67Z*E^=Wnpx4B04ZGGBqqB +zc4cgDV`+4GB04ZGH7p` + + +- ++ + +