Compare commits

...

131 Commits

Author SHA1 Message Date
admin 1102252e71 Merge branch 'main' into codex/fix-git-permission-denied-error-ipz1d6 2026-04-13 13:11:12 +02:00
admin 70f1802721 Fix MudBlazor analyzer issues and target x64 for HANA client 2026-04-13 13:10:01 +02:00
admin 2b9b40af93 Merge pull request #59 from metacube2/codex/fix-git-permission-denied-error-92qoc3
Add transformation rules UI and engine; add connection testing/status and Site SourceSystem
2026-04-13 12:20:43 +02:00
admin eb427ac608 Merge branch 'main' into codex/fix-git-permission-denied-error-92qoc3 2026-04-13 12:20:33 +02:00
admin 97e598fe3b Fix MudBlazor generic/value callback compile errors 2026-04-13 12:19:42 +02:00
admin 9406843988 Merge pull request #58 from metacube2/codex/fix-git-permission-denied-error
Add field transformation rules, UI, DB schema and integrate into export; improve HANA connection testing
2026-04-13 11:52:17 +02:00
admin ec827a4ce8 Add connection diagnostics and visual field transformation mapping 2026-04-13 11:52:05 +02:00
admin c4a93a7f15 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	TrafagSalesExporter/TrafagSalesExporter.csproj
2026-04-13 11:31:18 +02:00
admin 0d11315848 Merge pull request #57 from metacube2/codex/fix-git-permission-denied-error
Ignore Visual Studio workspace files in TrafagSalesExporter
2026-04-13 11:24:18 +02:00
admin c336c1c7f8 Ignore Visual Studio workspace files in TrafagSalesExporter 2026-04-13 11:24:04 +02:00
admin 3b6f66d0fb asdf 2026-04-13 11:22:40 +02:00
Claude af40d87213 Merge HANA SSL/MDC support and DLL reference fix from claude/blazor-sap-sales-exporter-9VrM0 2026-04-13 09:07:41 +00:00
Claude efcf7b180c Add SSL, MDC database, and custom HANA connection parameters
Fixes 'error while parsing protocol' HanaException by supporting
SSL/TLS encryption, Multi-Tenant Database Container (MDC) database
name, and arbitrary additional connection parameters.

- HanaServer model: added DatabaseName, UseSsl, ValidateCertificate,
  AdditionalParams fields + BuildConnectionString() helper
- HanaQueryService: accepts HanaServer directly, uses
  BuildConnectionString() for full parameter support
- AppDbContext: added EnsureSchema() method that uses PRAGMA table_info
  + ALTER TABLE ADD COLUMN to add the new fields to existing SQLite
  databases without losing data (EnsureCreated does not update schema)
- Program.cs: calls EnsureSchema on startup before seeding
- Standorte.razor: server dialog now exposes DatabaseName, UseSsl,
  ValidateCertificate, AdditionalParams with helper texts; test
  connection uses new signature

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-13 08:52:10 +00:00
Claude f916c26fb4 Fix SAP HANA client reference - use direct DLL reference instead of missing NuGet package
Das Paket 'Sap.Data.Hana.v2' existiert nicht auf nuget.org. SAP liefert den
HANA .NET Client ausschliesslich ueber das SAP HANA Client Installationspaket
aus. Stattdessen wird nun Sap.Data.Hana.Core.v2.1.dll direkt aus dem
Standard-Installationspfad referenziert (via HanaClientDll MSBuild-Property
ueberschreibbar). Warnung beim Build wenn DLL nicht gefunden wird.

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-10 06:23:59 +00:00
admin c198d362b1 Merge pull request #55 from metacube2/claude/blazor-sap-sales-exporter-9VrM0
Migrate from console app to Blazor web UI with database
2026-04-09 16:07:10 +02:00
Claude 8524631508 Convert TrafagSalesExporter from console app to Blazor Server app with MudBlazor UI
- Replaced console app with .NET 8 Blazor Server architecture
- Added EF Core SQLite database (trafag_exporter.db) with auto-seed data
- Models: HanaServer, Site, SharePointConfig, ExportSettings, ExportLog, SalesRecord
- Services: HanaQueryService (with configurable dateFilter), ExcelExportService,
  SharePointUploadService, ExportOrchestrationService (with live status events),
  TimerBackgroundService (scheduled daily export)
- MudBlazor UI pages: Dashboard (export status + manual trigger), Standorte
  (HANA server + site CRUD), Settings (SharePoint + timer config), Logs (filtered view)
- SAP HANA queries unchanged (INV + CRN with exact SAP B1 table joins)
- SharePoint upload via Microsoft Graph with app registration auth

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-09 14:00:44 +00:00
admin 2f56082adc Merge pull request #53 from metacube2/codex/create-c#-console-app-for-sap-hana-export
Add Trafag SAP HANA → Excel → SharePoint exporter (.NET 8 console)
2026-04-09 15:48:14 +02:00
admin 673bba7298 Add Trafag SAP HANA to Excel SharePoint exporter console app 2026-04-09 15:47:55 +02:00
admin 8d8b62f1f5 Merge pull request #51 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-02-09 10:47:23 +01:00
admin 99f40dd7ea Update fmt.Println message from 'Hello' to 'Goodbye' 2026-02-09 10:42:52 +01:00
admin bfcf018d33 Merge pull request #50 from metacube2/claude/php-video-converter-suite-SVNhO
Initial commit: Video Converter Suite application scaffold
2026-02-07 19:25:23 +01:00
Claude 6c56306873 Add PHP Video Converter Suite with live stream pipelines and nuclear control panel UI
Full-featured video conversion platform with:
- FFmpeg-based pipeline system with composable stages (transcode, scale, filter, audio, bitrate, framerate, trim, deinterlace, denoise, stabilize)
- Live stream management with real-time format switching (RTMP/RTSP/HTTP)
- Industrial/nuclear power plant control room themed UI with gauges, switches, LED indicators
- Format switchboard for instant conversion between 16+ video/audio formats
- Pipeline designer with visual flow editor and drag-and-drop stage composition
- Job queue with priority scheduling and batch conversion
- WebSocket server for real-time progress broadcasting
- REST API for all operations (upload, convert, streams, pipelines, queue)
- System monitoring (CPU, memory, disk) with animated gauge displays
- Docker Compose setup with web, websocket, and worker services

https://claude.ai/code/session_01WxmHGnVFXGm2bwbFREHkHb
2026-02-07 18:11:04 +00:00
admin 2cc77f5405 Merge pull request #49 from metacube2/claude/product-promotion-tool-jY9ZZ
Add PromoMaster - automatic product promotion tool in DE/EN
2026-02-07 19:06:56 +01:00
admin 47487c7bab Merge pull request #47 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-02-07 19:04:02 +01:00
admin fe502fc4b3 Merge branch 'main' into claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw 2026-02-07 19:03:54 +01:00
admin df86a5f568 Merge pull request #48 from metacube2/claude/photography-homepage-tools-1bcI6
Add comprehensive styling for photography tools application
2026-02-07 19:02:10 +01:00
Claude 3f0662c49a feat: Add Turkish/Swedish translations and 6 photography simulation tools
- Added Turkish (TR) and Swedish (SV) language support to i18n system
- Added simulation tool i18n keys to all 8 languages
- New simulation section with 6 interactive canvas-based tools:
  Bokeh, Long Exposure, White Balance, ISO Noise, Perspective, Histogram
- Updated hero stats counter from 6 to 12 tools
- Added simulation nav link and footer link

https://claude.ai/code/session_016BnRMtz5yhf7n5ZPQCMfmN
2026-02-07 13:15:58 +00:00
Claude 3b6d0c4db5 Add PromoMaster - automatic product promotion tool in DE/EN
Full-featured web tool for generating product marketing materials:
- Social media posts for Twitter/X, Instagram, Facebook, LinkedIn, TikTok
- 6 promotion styles: Professional, Casual, Urgent/FOMO, Luxury, Fun, Minimal
- Email marketing templates
- SEO texts & metadata generator
- Press release generator
- Slogan & tagline generator
- Hashtag cloud
- Landing page HTML generator with live preview
- Export to TXT, HTML, JSON, CSV
- Complete DE/EN bilingual support with language toggle
- SEO meta tags, Open Graph, Twitter Cards, JSON-LD structured data
- Fully responsive dark-mode design

https://claude.ai/code/session_01BkvFWWTbZTBY6KafPCpXGF
2026-02-07 13:14:49 +00:00
Claude 0605aee88d feat: Add PhotoPro Tools - photography homepage with 6 languages
Complete photography toolkit with:
- 6 lens calculators (DOF, FOV, crop factor, hyperfocal, flash range, magnification)
- 8 interactive composition rules with canvas demos (thirds, golden ratio, leading lines, symmetry, framing, negative space, diagonals, color theory)
- Motif recognition guide for 8 genres (portrait, landscape, street, macro, night, sport, architecture, wildlife) with optimal camera settings
- Interactive exposure triangle visualization with real-time EV meter
- Quiz system with 50 questions across 5 categories with instant feedback
- Full i18n support: Deutsch, English, Français, Italiano, Srpski, Shqip
- Modern dark UI with glassmorphism, particles, animations
- Fully responsive design

https://claude.ai/code/session_016BnRMtz5yhf7n5ZPQCMfmN
2026-02-07 12:50:44 +00:00
admin 6fba9d938a Remove OpenWeatherMap API key input and update settings
Removed API key input for OpenWeatherMap and updated weather widget settings.
2026-02-05 11:03:08 +01:00
admin 5d9ebbbc3e Refactor weather settings and timelapse checks
Removed the weather API key retrieval method and updated the weekly timelapse check logic.
2026-02-05 11:02:36 +01:00
admin 282d8b70fc Merge branch 'main' into claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw 2026-02-05 11:01:33 +01:00
admin 814494f812 Add auto-screenshot and email sharing settings 2026-02-05 10:57:02 +01:00
admin 75e5566532 Update weather widget description and remove API key field 2026-02-05 10:56:21 +01:00
admin 5d382db42e Merge pull request #46 from metacube2/codex/implement-phase-locked-phase-vocoder
Add phase-locked vocoder timestretcher
2026-01-31 12:24:13 +01:00
admin 054717fff1 Add phase-locked vocoder timestretcher 2026-01-31 12:24:02 +01:00
admin 5218c064cb Merge pull request #45 from metacube2/claude/fix-aurora-api-key-VK588
Add 4 new features: timelapse toggle, auto-screenshot, video search, …
2026-01-30 19:42:09 +01:00
Claude 6a24b564a4 Add 4 new features: timelapse toggle, auto-screenshot, video search, email sharing
- Weekly timelapse button now toggleable via settings (zoom_timelapse.weekly_timelapse_enabled)
- Auto-screenshot API for cron-based gallery capture every 10 min
- Date/time video search with filter UI in archive section
- Email sharing with share links and PHPMailer integration
- New API endpoints: auto-screenshot.php, gallery.php, video-search.php, share.php
- New settings: auto_screenshot.*, sharing.* for feature configuration
2026-01-30 18:33:54 +00:00
admin cc85523c9c Merge pull request #44 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
a
2026-01-23 21:24:58 +01:00
admin bb27cb151e Merge pull request #43 from metacube2/claude/fix-aurora-api-key-VK588
Claude/fix aurora api key vk588
2026-01-23 21:24:00 +01:00
Claude 16673b91d3 Add Billing/Stripe integration and Landing Page (Phase 4+5)
Phase 4 - Billing/Stripe:
- src/Billing/StripeService.php: Stripe API wrapper
  - Checkout session creation
  - Customer management
  - Billing portal sessions
  - Webhook signature verification
- src/Billing/SubscriptionManager.php: Subscription logic
  - Plan management (CRUD)
  - Trial handling
  - Feature access checks
  - Invoice storage
- src/Billing/WebhookHandler.php: Stripe webhook processing
  - checkout.session.completed
  - customer.subscription.* events
  - invoice.paid / payment_failed
- api/stripe-webhook.php: Webhook endpoint
- dashboard/billing.php: Billing dashboard
  - Current plan display with features
  - Plan comparison grid
  - Upgrade buttons with Stripe Checkout
  - Invoice history

Phase 5 - Landing Page:
- landing/index.php: Marketing homepage
  - Hero section with CTA
  - Feature grid (6 features)
  - How it works (3 steps)
  - Final CTA section
  - Responsive design
- landing/pricing.php: Pricing page
  - Dynamic plan cards from DB
  - Monthly/yearly toggle (2 months free)
  - Feature comparison
  - FAQ accordion

All features respect saas_features toggles in settings.
2026-01-23 19:16:18 +00:00
Claude ac77e27089 Add automatic onboarding system (Phase 3)
Onboarding Wizard:
- register.php: User registration with validation
- verify.php: Email verification (with demo mode)
- stream.php: Stream URL configuration & validation
- branding.php: Quick branding setup with live preview
- complete.php: Success page with confetti animation

Backend Classes (src/Onboarding/):
- OnboardingManager.php: Orchestrates the onboarding flow
  - Registration with automatic subdomain generation
  - Email verification tokens
  - Step tracking in tenant_onboarding table
- StreamValidator.php: Validates stream URLs
  - HLS (.m3u8) validation with playlist check
  - RTMP format validation
  - iframe/embed URL detection (YouTube, Vimeo, Twitch)
  - Generic HTTP reachability check

Features:
- 4-step wizard with progress indicator
- Stream type auto-detection
- Live branding preview
- Skip options for optional steps
- Trial period display
2026-01-23 18:41:53 +00:00
Claude 7bd62b3527 Add tenant dashboard (Phase 2)
Dashboard Features:
- Login page with session-based auth
- Overview page with live stats (viewers, stream status)
- Stream settings (URL, type configuration)
- Branding editor (colors, texts, custom CSS)
- Settings page (weather, content toggles, UI options)

New Files:
- dashboard/index.php: Main overview with stats
- dashboard/login.php: Authentication page
- dashboard/logout.php: Session cleanup
- dashboard/stream.php: Stream configuration
- dashboard/branding.php: Visual customization
- dashboard/settings.php: Feature toggles
- dashboard/templates/layout.php: Shared layout
- dashboard/api/stats.php: Stats API endpoint
- dashboard/assets/dashboard.css: Modern dashboard UI
- dashboard/assets/dashboard.js: Client-side functionality
- src/Auth/AuthManager.php: Secure auth with Argon2, remember-me

Auth Features:
- Secure password hashing (Argon2ID)
- Remember-me tokens
- Role-based access (super_admin, tenant_admin, tenant_user)
- Legacy fallback for existing admin credentials
2026-01-23 17:09:38 +00:00
Claude 402604b4cc Add Multi-Tenant SaaS foundation for customer management
Phase 1 implementation includes:

Database:
- schema.sql with tables for tenants, domains, settings, branding,
  streams, users, subscriptions, plans, invoices, viewer_stats

Core Classes (src/Core/):
- Database.php: PDO wrapper with singleton pattern
- TenantResolver.php: Domain-to-tenant resolution with fallback

Tenant Classes (src/Tenant/):
- TenantManager.php: CRUD operations for tenants
- TenantSettingsManager.php: DB-based settings per tenant

Configuration:
- config.example.php: Template for database/stripe/mail config
- bootstrap.php: Initializes multi-tenant environment
- .gitignore: Excludes config.php and cache files

Integration:
- SettingsManager.php: Added saas_features toggles (all off by default)
- index.php: Uses getSiteConfig() from bootstrap when multi-tenant enabled,
  falls back to legacy hardcoded domains when disabled

All SaaS features are disabled by default (saas_features.multi_tenant_enabled = false),
ensuring zero breaking changes to existing installations.
2026-01-23 16:40:42 +00:00
admin 4acdf89588 Merge pull request #42 from metacube2/codex/fix-missing-api-key-in-index.php
Add optional OpenWeatherMap API key support for weather widget
2026-01-22 23:01:34 +01:00
admin 20c0569731 Add optional weather API key support 2026-01-22 23:00:41 +01:00
admin 144c813acf Merge pull request #41 from metacube2/claude/fix-aurora-api-key-VK588
Prevent caching of API errors in WeatherManager
2026-01-22 22:40:57 +01:00
Claude 328b5b5b15 Fix cached weather error being returned
The weather cache was returning old errors (like "API Key fehlt"
from the previous OpenWeatherMap implementation) even after
switching to Open-Meteo which doesn't require an API key.

Changes:
- Delete cache file if it contains an error
- Prevent errors from being cached in the first place
2026-01-22 21:40:09 +00:00
admin 6472bbf162 Merge pull request #40 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-01-22 22:30:35 +01:00
admin c66a5b9f64 Merge pull request #39 from metacube2/claude/fix-layout-centering-cdX7d
Add error handling to weather widget
2026-01-22 22:29:27 +01:00
Claude 7e468d51ca Add error handling to weather widget
- Wrap getCurrentWeather() in try-catch block
- Prevents white screen if weather API fails
- Shows error message in widget instead
- Add test-weather.php for debugging
2026-01-22 21:06:50 +00:00
admin 7b3f99e837 Merge pull request #38 from metacube2/claude/fix-layout-centering-cdX7d
Switch weather API from OpenWeatherMap to Open-Meteo
2026-01-22 22:00:25 +01:00
Claude 20704b3cd8 Switch weather API from OpenWeatherMap to Open-Meteo
🎉 **100% KOSTENLOS - KEIN API KEY MEHR NÖTIG!**

**Warum Open-Meteo?**
-  Komplett kostenlos ohne Limits
-  KEIN API Key erforderlich
-  Keine Kreditkarte, keine Anmeldung
-  Open Source & Non-Profit
-  Sehr präzise Daten für die Schweiz
-  Schnell & zuverlässig

**Technische Änderungen:**

**WeatherManager.php:**
- API URL auf api.open-meteo.com gewechselt
- WMO Weather Codes (0-99) implementiert
- Deutsche Wetterbeschreibungen für alle Codes
- Temperatur, Wind, Luftdruck, Feuchtigkeit, Niederschlag
- Icon-Mapping von WMO Codes zu Emojis
- Timezone: Europe/Zurich

**index.php Admin-Panel:**
- API Key Feld entfernt (nicht mehr nötig!)
- Überschrift: "Open-Meteo - 100% kostenlos!"
- Event Listener für API Key entfernt
- Funktioniert sofort ohne Setup!

**API Endpoint:**
https://api.open-meteo.com/v1/forecast
- Keine Authentication nötig
- Parameter: lat, lon, current weather vars
- Response: JSON mit aktuellen Wetterdaten

**Unterstützte Wetter-Codes:**
- 0: Klar
- 1-3: Bewölkt (verschiedene Grade)
- 45-48: Nebel
- 51-65: Regen (Niesel bis stark)
- 71-77: Schnee
- 80-86: Schauer
- 95-99: Gewitter & Hagel

**Resultat:**
Widget funktioniert SOFORT ohne jegliches Setup!
Einfach aktivieren und fertig! 🌤️
2026-01-22 20:59:07 +00:00
admin 6fa64baa35 Merge pull request #37 from metacube2/claude/fix-layout-centering-cdX7d
Fix undefined siteConfig variable in admin panel
2026-01-22 21:52:18 +01:00
Claude 6bca898488 Fix undefined siteConfig variable in admin panel
- Add global $siteConfig declaration in displayAdminContent()
- Fixes warning in SEO settings placeholder
2026-01-22 20:50:53 +00:00
admin 8bd90629f9 Merge pull request #36 from metacube2/claude/fix-layout-centering-cdX7d
Claude/fix layout centering cd x7d
2026-01-22 21:39:19 +01:00
Claude 7eda2fbbe8 Add weather widget with OpenWeatherMap integration
Implementiert vollständiges Wetter-Widget oberhalb des Webcam-Videos:

**Features:**
🌤️ **Wetter-Anzeige:**
- Temperatur (°C)
- Windgeschwindigkeit & Richtung (km/h)
- Luftdruck (hPa)
- Luftfeuchtigkeit (%)
- Wetterbeschreibung mit Emoji-Icons
- Niederschlag (Regen/Schnee) wenn vorhanden

⚙️ **Technisch:**
- OpenWeatherMap API Integration
- 5 Minuten Cache (konfigurierbar)
- Auto-Update alle X Minuten (Frontend)
- WeatherManager Klasse für Backend
- Schönes Gradient-Design mit Hover-Effekten
- Responsive für Mobile

🎛️ **Admin-Settings:**
- Wetter-Widget ein/aus
- API Key Eingabefeld + Registrierungs-Link
- Standort konfigurierbar (Stadt,Land)
- GPS-Koordinaten (Lat/Lon)
- Update-Intervall (5-60 Minuten)
- Einheiten (Metrisch/Imperial)

**Dateien:**
- WeatherManager.php: Neue Klasse für API-Calls & Caching
- SettingsManager.php: Weather Settings Defaults & Helper
- index.php: Widget HTML, CSS, JavaScript Auto-Update
- settings.json: Weather Defaults initialisiert

**Koordinaten Oberdürnten:**
Lat: 47.2833, Lon: 8.7167

**Setup für User:**
1. Gratis Account auf openweathermap.org erstellen
2. API Key im Admin-Panel einfügen
3. Fertig! Widget zeigt Live-Wetter an
2026-01-22 18:50:16 +00:00
Claude 5b8200a4ff Add cache clear utility script
- Add clear-cache.php for clearing PHP OPcache and realpath cache
- Useful for debugging and ensuring latest code changes are visible
2026-01-22 17:57:17 +00:00
Claude ac6632e24f Initialize settings.json with all new defaults
- Add all new settings groups (ui_display, zoom_timelapse, content, technical, theme, seo)
- Set default values for all new admin settings
- Enable all features by default for smooth transition
2026-01-22 17:54:57 +00:00
Claude 0ce527c69e Add comprehensive admin settings control panel
Erweitere Admin-Bereich um umfangreiche Settings-Steuerung:

**Punkt 2 - UI Anzeige:**
- Empfehlungs-Banner ein/aus
- QR-Code Section ein/aus
- Social Media Links ein/aus
- Patrouille Suisse Section ein/aus

**Punkt 3 - Zoom & Timelapse:**
- Zoom-Controls anzeigen/verstecken
- Max Zoom-Level konfigurierbar (1.5x - 4.0x)
- Timelapse Rückwärts-Modus ein/aus

**Punkt 5 - Content Management:**
- Gästebuch aktivieren/deaktivieren
- Galerie aktivieren/deaktivieren
- KI-Events anzeigen/verstecken
- Max Gästebuch-Einträge limit

**Punkt 6 - Technische Settings:**
- Viewer Update-Intervall konfigurierbar
- Session Timeout einstellbar

**Punkt 7 - Theme & Design:**
- Standard-Theme auswählbar (Legacy/Alpine/Modern)
- Theme-Switcher anzeigen/verstecken (war auskommentiert)

**Punkt 8 - SEO & Meta:**
- Custom Title konfigurierbar
- Meta Description editierbar
- Meta Keywords verwaltbar

**Technische Änderungen:**
- SettingsManager.php: Neue Defaults und Helper-Methoden
- Admin-Panel: Neue Settings-Gruppen mit Toggle-Switches
- JavaScript: Live-Apply ohne Reload für alle Settings
- HTML: Sections mit PHP-Settings verbunden
- CSS: Admin-Panel Styling hinzugefügt
- TimelapseController: reverseEnabled Setting integriert
2026-01-22 17:47:56 +00:00
admin 8f46ffb695 Merge pull request #35 from metacube2/claude/fix-layout-centering-cdX7d
Center header layout and adjust navigation alignment
2026-01-22 18:26:26 +01:00
Claude 36558e97cb Fix layout centering in aurora-livecam
- Center header container and navigation
- Add padding-right to header to prevent overlap with language selector buttons
- Change nav ul justify-content from space-around to center for better alignment
2026-01-22 17:25:58 +00:00
admin 1cf30a0c8b Update fmt.Println message from 'Hello' to 'Goodbye' 2026-01-22 18:17:50 +01:00
admin ff4bda1e53 Merge pull request #34 from metacube2/codex/create-power-bi-training-manual-for-hr
Add HR Power BI consumer and trainer manuals
2026-01-20 13:11:19 +01:00
admin af87ef329b Merge branch 'main' into codex/create-power-bi-training-manual-for-hr 2026-01-20 13:11:10 +01:00
admin 9c1d820876 Add HR Power BI consumer and trainer manuals 2026-01-20 13:08:29 +01:00
admin cbe215cfc2 Merge pull request #33 from metacube2/codex/create-power-bi-training-manual-for-hr
Add HTML Power BI HR manual with embedded SVG diagram
2026-01-20 09:52:43 +01:00
admin 12d64bf009 Expand HTML Power BI HR manual 2026-01-20 09:02:03 +01:00
admin 0871068ff8 Add HTML Power BI HR manual 2026-01-20 08:08:49 +01:00
admin 3d7cee81da Merge pull request #32 from metacube2/codex/create-power-bi-training-manual-for-hr
Provide Power BI HR training manual (Markdown; omit Word binary)
2026-01-20 07:49:14 +01:00
admin 0aa5a62754 Provide Power BI HR training manual 2026-01-20 07:48:59 +01:00
admin f487713a92 Add Power BI training manual for HR
Created a comprehensive training manual for Power BI tailored for HR staff, including step-by-step instructions, target audience details, data sources, KPIs, and troubleshooting tips.
2026-01-20 07:41:01 +01:00
admin 5eb27d1c28 Merge pull request #31 from metacube2/codex/check-translations-in-index2.php
Complete missing translations for language switcher and page content (IT/FR/ZH)
2026-01-19 22:25:50 +01:00
admin 4060115749 Complete missing translations in index2 2026-01-19 22:25:15 +01:00
admin b98a6761c2 Merge pull request #30 from metacube2/claude/seecam-domain-config-njiwU
Add missing translations for Patrouille Suisse and Blog sections
2026-01-18 17:14:56 +01:00
Claude 6a8e879898 Add missing translations for Patrouille Suisse and Blog sections
- About section: additional paragraph translated
- Admin section: headings translated
- Patrouille Suisse: complete section with all texts, headings and lists
- Blog section: subtitle, all 3 articles with titles, dates and content
- Footer blog text translated

All elements now have data-en and data-de attributes for language switching
2026-01-18 13:23:33 +00:00
admin b9407a9f13 Merge pull request #28 from metacube2/claude/seecam-domain-config-njiwU
Add domain-based site configuration for seecam.ch
2026-01-18 10:08:40 +01:00
Claude 53ae1ba76f Add domain-based site configuration for seecam.ch
- Detect domain (seecam.ch vs aurora-weather-livecam.com) via PHP
- Load different logo (seecam.jpg vs logo.png) based on domain
- Dynamic meta tags (title, og:*, twitter:*, canonical URL)
- Dynamic Schema.org JSON-LD (WebSite, LocalBusiness, VideoObject)
- Dynamic site texts (welcome, about, blog title, footer, copyright)
- QR-code URL adapts to current domain
- Email sender name remains unchanged (Aurora Livecam)
2026-01-18 09:06:02 +00:00
admin 9a418aa213 Merge pull request #27 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-17 18:18:37 +01:00
Claude 9a88b5cad2 Add SEO-optimized index2.php with comprehensive improvements
- Add SEO meta tags (title, description, keywords, robots)
- Add Schema.org JSON-LD structured data (WebSite, LocalBusiness, VideoObject)
- Add local SEO geo tags for Zürich Oberland/Dürnten
- Improve Open Graph and Twitter Card meta tags
- Add optimized alt-texts for images
- Add social media links to footer (Instagram, Facebook, YouTube, TikTok)
- Add Patrouille Suisse dedicated section with training info
- Add Blog section with weather and webcam articles
- Add accessibility CSS for screen-readers
2026-01-17 17:13:46 +00:00
admin 3e6a584f4f Merge pull request #25 from metacube2/claude/add-macos-support-uYBaj
Add macOS Catalyst support for RollkofferSimulator
2026-01-15 15:24:17 +01:00
admin 6ad6167c52 Merge pull request #24 from metacube2/main-aurora
Main aurora
2026-01-15 15:10:46 +01:00
Claude f9b84e4d3c Add separate zoom wrapper layers for all video modes
- Added live-video-wrapper around webcam-player
- Added timelapse-wrapper inside timelapse-viewer
- Added daily-video-wrapper inside daily-video-player
- Zoom now applies to wrapper divs, not video elements directly
- Pan works by dragging inside video container when zoomed
- Double-click to reset zoom
- Cursor changes to grab when zoomed > 1x
- Touch support for mobile pan
2026-01-15 14:09:42 +00:00
admin 2e6fd332ac Merge pull request #23 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-15 14:54:33 +01:00
Claude b5376f46e5 Merge zoom & pan improvements from main-aurora 2026-01-15 13:51:42 +00:00
Claude 98f1fcae14 Add zoom & pan for all video modes
- Zoom now works for livestream, timelapse and daily videos
- Added pan function: drag to move zoomed area with mouse
- Added touch support for mobile pan
- Added +/- zoom buttons and reset button
- Reduced max zoom from 100x to 4x
- Dynamically detects active video element
- Pan limits based on zoom level
- Cursor changes to grab when zoomed
2026-01-15 13:51:25 +00:00
Claude 14c064de64 Fix video overlay issue - add z-index and disable zoom temporarily
- Added z-index: 10 to webcam-player (higher than overlays)
- Added z-index: 5 and display: none to timelapse/daily-video overlays
- Disabled zoom script temporarily for testing
2026-01-13 09:39:44 +00:00
Claude 313c2108a9 Fix live video player - add native HLS support and debugging
- Added display:block and background:#000 to video element
- Added native HLS detection for Safari (canPlayType check)
- Added console.log debugging for video loading
- Added error event handlers for better debugging
- Added fallback for browsers without HLS support
- Improved HLS.js error handling
2026-01-13 09:34:42 +00:00
admin c12ac16557 Merge pull request #22 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-13 10:27:17 +01:00
Claude b686d4506c Update aurora-livecam with new design and fixed zoom
- New design from main-aurora branch
- Fixed zoom: maxZoom reduced from 100 to 4
- Added zoom +/- buttons
- Added zoom slider with step 0.5
- Fixed video-zoom.js to not apply transform at 1x
2026-01-13 09:26:08 +00:00
admin c38bd130e5 Merge pull request #21 from metacube2/codex/fix-saving-changes-in-audora-project-tgc6un
Add 1–100x zoom controls for all video modes and wire video-zoom.js
2026-01-12 12:59:49 +01:00
admin de343364ad Merge branch 'main-aurora' into codex/fix-saving-changes-in-audora-project-tgc6un 2026-01-12 12:58:08 +01:00
admin e8385adb87 Add zoom controls for video modes 2026-01-12 12:40:22 +01:00
admin f7843e5e35 Merge pull request #20 from metacube2/codex/fix-saving-changes-in-audora-project-4bnojk
Add design switcher (Alpine/Modern) with Swiss-cross and sun overlay; harden settings save
2026-01-12 12:27:48 +01:00
admin 3a78d09399 Add design switcher themes 2026-01-12 12:27:32 +01:00
admin 28d2032f23 Merge pull request #19 from metacube2/codex/fix-saving-changes-in-audora-project
Fix settings save path and improve save reliability
2026-01-12 11:33:52 +01:00
admin 1ec8d734ee Fix settings save path 2026-01-12 11:33:33 +01:00
admin 60dab1e9df Add advertisement banner styles and functionality 2026-01-12 11:26:12 +01:00
Claude 9e175fdf56 Add indexmiau.php as copy of index.php 2026-01-11 03:23:53 +00:00
admin 13024c5ae8 Merge pull request #18 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-11 03:49:25 +01:00
Claude 42b12c5c36 Add zoom for all video modes and fix settings saving
- Zoom now works on live video, timelapse images, and archive video player
- Added zoom level indicator (shows percentage)
- Increased max zoom from 3x to 4x
- Fixed settings AJAX handler using FormData for reliable POST
- Settings event handlers now properly bound after DOM load
- Added error handling and visual feedback for settings changes
2026-01-11 02:48:33 +00:00
Claude a033d15912 Refactor index.php with cleaner AJAX handling and simplified code
- Inline AJAX settings handler for better control flow
- Simplify video download logic with condensed code
- Clean up domain redirect handling
- Remove redundant headers and verbose comments
2026-01-11 02:34:26 +00:00
admin 1f9bc08682 Merge pull request #17 from metacube2/main-aurora
Merge pull request #16 from metacube2/claude/mail-finetuning-webapp-0…
2026-01-10 11:49:50 +01:00
admin 191381ece4 Merge pull request #16 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
neue funktionen aurora index
2026-01-10 11:49:22 +01:00
admin fabdfb121a Merge pull request #15 from metacube2/claude/add-video-download-sppLI
Add complete index.php with all video player enhancements
2026-01-10 11:48:02 +01:00
Claude 4454adca59 Add complete index.php with all video player enhancements
Features integrated:
- Timelapse controls: slider, speed (1x/10x/100x), reverse playback
- Daily video player: plays videos in main player with controls
- Back to Live button for both timelapse and daily videos
- Admin settings panel: viewer display toggle, min viewers, video mode
- Conditional viewer count display based on admin settings
- AJAX settings updates without page reload
- SettingsManager integration throughout the application
2026-01-10 10:19:04 +00:00
admin 9ae417cb03 Merge pull request #14 from metacube2/claude/add-video-download-sppLI
Implement video download functionality
2026-01-10 11:10:29 +01:00
Claude 367aa4c67b Add Aurora Livecam video player enhancements
- Add SettingsManager class for admin settings (settings.json)
- Add timelapse controls with slider, speed (1x/10x/100x), and reverse playback
- Add daily video player to play videos in main player window
- Add admin settings panel for viewer display and video mode configuration
- Add CSS styles for new player controls
- Include integration guide for existing index.php
2026-01-10 10:08:17 +00:00
admin cac3768885 Merge pull request #13 from metacube2/claude/healthbridge-sync-app-XxRm8
Build intelligent health data synchronization app
2025-12-25 18:00:52 +01:00
Claude b953908f58 Add HealthBridge iOS app for intelligent health data synchronization
Complete implementation of a SwiftUI iOS app that serves as a "Single Source
of Truth" for health data. The app reads from all Apple Health sources,
detects conflicts between devices, merges data using configurable strategies,
and writes cleaned data back.

Features:
- Phase 1: HealthKit integration with automatic source discovery
- Phase 2: DataReader with conflict detection (time-window based)
- Phase 3: RuleEngine with 8 merge strategies (exclusive, priority, higher wins, etc.)
- Phase 4: MergeEngine for conflict resolution + DataWriter for HealthKit writes
- Phase 5: SwiftUI UI for dashboard, conflicts, rules, and sources management
- Phase 6: Background sync with configurable intervals and push notifications
- Phase 7: Complete rule editor and polished UI components

Supported data types:
- Steps, Heart Rate, Blood Pressure, SpO2, Sleep
- Distance, Floors Climbed, Active Energy, HRV, Respiratory Rate

Architecture: SourceManager -> DataReader -> RuleEngine -> MergeEngine -> DataWriter
2025-12-25 16:59:48 +00:00
admin 0ffb1c771e Merge pull request #12 from metacube2/claude/dctp-delta-transfer-swz4W
Build Delta Code Transfer Protocol tool
2025-12-25 14:51:10 +01:00
Claude 8858a08a32 Add DCTP (Delta Code Transfer Protocol) tool for efficient AI code transfers
DCTP enables efficient transfer of AI-generated code using delta operations
instead of sending complete files for each modification. Features include:

- Parser for DCTP control commands (NEW, DELETE, INSERT_AFTER, REPLACE, RENUMBER)
- Line-numbered code with language-specific comment formats
- Backup/Undo system with session management
- Diff generation for preview functionality
- CustomTkinter GUI with project management, preview, and diff views
2025-12-25 12:59:07 +00:00
admin faa36d0e5e Merge pull request #11 from metacube2/claude/family-albums-portal-jhT0O
Build family photo album portal backend
2025-12-25 11:52:32 +01:00
Claude 25766959f1 Add FamilyAlbums family photo portal with Nextcloud integration
A PHP-based family photo album portal featuring:
- Public gallery with year/month filtering and search
- Mobile-responsive design with Tailwind CSS
- Comment system for family members
- Admin interface for album management
- Flat-file JSON database (no MySQL needed)
- CSRF protection and XSS prevention
- Rate limiting and honeypot spam protection
2025-12-25 09:57:51 +00:00
admin 5f949121bf Merge pull request #10 from metacube2/claude/psytrance-visualizer-swift-bFGOw
Build Psytrance Visualizer with Swift and Metal
2025-12-22 22:38:56 +01:00
Claude a22c238dc4 Add Psytrance Visualizer macOS app with Metal rendering
A complete audio-reactive visualizer for psytrance music featuring:

Audio Analysis (DSPEngine):
- FFT spectrum analysis via Accelerate/vDSP
- 64-band Mel spectrogram
- Sub-bass energy extraction (<100Hz)
- Automatic sidechain pump detection
- Harmonic-to-Noise ratio (HNR) calculation
- Peak/transient detection

8 Visualization Modes (Metal Shaders):
1. FFT Classic - Frequency spectrum bars with glow
2. Mel Spectrogram - Waterfall display
3. Sub-Bass - Pulsating rings
4. Sidechain Pump - Breathing zoom effect
5. Harmonic/Noise - Geometric vs chaotic particles
6. Mandelbrot - Audio-reactive fractal zoom
7. Tunnel Warp - Infinite tunnel with distortion
8. DMT Geometry - Sacred geometry patterns

Features:
- Selectable audio input device (BlackHole support)
- Configurable buffer size (512/1024)
- Reactivity slider for visual intensity
- Auto-hiding control panel
- Fullscreen support with keyboard shortcuts (1-8, F, ESC)
- Persistent settings via UserDefaults
- Psytrance-inspired neon/UV color palette
2025-12-22 21:36:45 +00:00
Claude 9363a2dd99 Add macOS Catalyst support for RollkofferSimulator
- Enable Mac Catalyst in Xcode project (SUPPORTS_MACCATALYST=YES)
- Set macOS deployment target to 13.0 (Ventura+)
- Add keyboard support for all scenes (Escape, Space, Enter)
- Add macOS menu bar with game commands (Cmd+P pause, Cmd+R restart)
- Configure window size restrictions for macOS
- Update Info.plist with macOS minimum version
2025-12-20 17:46:26 +00:00
admin b607a9cd8a Merge pull request #9 from metacube2/claude/suitcase-arcade-game-6y4do
Add RollkofferSimulator iOS SpriteKit arcade game
2025-12-20 18:20:36 +01:00
Claude 9e501cc4e8 Add RollkofferSimulator iOS SpriteKit arcade game
Complete implementation of a 2D top-down arcade collector game where players
control a rolling suitcase through an airport, collecting good dogs and green
people while avoiding bad dogs and gray people.

Features:
- Touch & drag controls for suitcase movement
- Automatic scrolling airport floor with tile pattern
- 4 entity types: good dogs (small/big), bad dogs, green/gray humans
- Spawn system with configurable distribution rates
- Collision detection with visual feedback effects
- Score tracking with high score persistence
- 90-second time limit with 10 dogs + 5 humans goal
- 3 lives system with invincibility frames
- Menu, Game, GameOver, and Victory scenes
- German UI text (Created by Ingo K.)

Technical:
- iOS 15+ with SpriteKit framework
- Modular architecture with Nodes, Managers, Scenes
- Physics-based collision detection
- UserDefaults for score persistence
2025-12-19 16:47:49 +00:00
admin 8d4afcf0ad Merge pull request #8 from metacube2/claude/ft991a-remote-control-app-T356a
FT-991A Remote Control App for macOS
2025-12-18 12:01:52 +01:00
Claude 1e153f2f85 Add FT-991A Remote Control App for macOS
Complete Phase 1 implementation of the Yaesu FT-991A remote control
application with CAT protocol support over USB serial (CP210x).

Features implemented:
- SerialPortManager with auto-detection of CP210x ports
- Full CAT protocol parser and command builder
- RadioState model with all transceiver parameters
- Modern SwiftUI interface with frequency/mode/level controls
- Skeuomorphic front panel view (switchable)
- Debug panel with CAT command console
- QSO log panel with CSV export/import
- Audio routing panel with BlackHole integration
- Settings with connection, UI, keyboard configuration
- Menu bar extra for background operation
- German/English localization
- Logging system for debugging

Supports: Frequency control, VFO A/B, all modes (LSB/USB/CW/FM/AM/
DATA/RTTY/C4FM), level controls, NB/NR/DNF/ATU/Split functions,
S-meter/Power/SWR metering, PTT control via Shift key.

Target: macOS 15.0+ (Sequoia/Tahoe)
2025-12-18 10:59:15 +00:00
admin 20904e2a96 Refactor SerialManager for VU1 Hub support
Refactor SerialManager for VU1 Dials Hub communication, update protocols, and improve connection management.
2025-12-14 22:06:09 +01:00
admin 841d1c09fc Merge pull request #6 from metacube2/claude/macos-audio-vu-meter-j8fVB
Add USB auto-probing to detect VU meter hardware
2025-12-14 11:46:57 +01:00
Claude f5e266b22b Add USB auto-probing to detect VU meter hardware
Features:
- Auto-probe scans all USB serial ports to find VU meter
- Tests multiple baud rates (115200, 9600, 57600, 38400, 19200)
- Tests all protocols (Raw, Text, JSON, VU-Server)
- Detects response from hardware to confirm connection
- Known USB device detection (CH340, CP210x, FTDI, Arduino, etc.)
- USB Vendor/Product ID display in port selection
- Quick Connect button for instant auto-connection
- Progress bar and status during probing
- Probe results display for debugging

USB detection:
- Reads USB idVendor/idProduct from IOKit registry
- Marks known VU meter devices with star icon
- Auto-selects detected VU meter port
2025-12-14 10:41:27 +00:00
admin 3dd5a1891f Merge pull request #5 from metacube2/claude/macos-audio-vu-meter-j8fVB
Add physical VU meter hardware support (4 dials)
2025-12-14 11:27:59 +01:00
Claude 52fa522d6d Add physical VU meter hardware support (4 dials)
New features:
- SerialManager for USB/Serial communication with hardware
- Support for 4 physical VU meter dials
- Flexible channel mapping: Audio L/R, Peak, Mono, CPU, RAM, Disk, Network
- Multiple protocols: Raw bytes, Text, JSON, VU-Server compatible
- Per-dial configuration: min/max values, inversion, smoothing
- Hardware panel in main view showing dial status
- Hardware settings sheet for configuration
- Auto-detection of USB serial devices

Protocol formats:
- Raw: [0xAA][D1][D2][D3][D4][0x55]
- Text: CH1:val;CH2:val;CH3:val;CH4:val\n
- JSON: {"dials":[d1,d2,d3,d4]}
- VU-Server: #0:val\n#1:val\n...
2025-12-14 10:15:20 +00:00
admin e4e08037c3 Merge pull request #4 from metacube2/claude/macos-audio-vu-meter-j8fVB
Create macOS app for audio level monitoring
2025-12-14 11:05:17 +01:00
Claude 2ad21cad58 Add macOS Audio VU Meter app with system monitoring
Features:
- Real-time audio level monitoring via BlackHole virtual audio device
- Classic VU meter display with dB scale (-60 to 0 dB)
- Peak hold indicators with configurable hold time
- System resource monitors: CPU, RAM, Disk, Network
- SwiftUI interface with dark theme
- Multi-device audio input selection
- Settings window for configuration

Built with AVAudioEngine for audio capture and Mach kernel APIs
for system statistics.
2025-12-14 10:03:56 +00:00
admin dd1d45d3e0 Merge pull request #3 from metacube2/claude/twelve-tone-synthesizer-01BgdmRVwhTdP8FRvAbntAqo
Build twelve-tone synthesizer with reverb in PHP
2025-12-13 17:29:21 +01:00
Claude a93e940b71 Add Twelve-Tone Synthesizer - Dodekaphonie nach Schönberg
Complete web-based synthesizer implementing Arnold Schönberg's
twelve-tone technique (Dodekaphonie) with:

- PHP backend for tone row generation and matrix calculation
- JavaScript Web Audio API for real-time sound synthesis
- Four row transformations: Original, Retrograde, Inversion, RI
- Convolver-based reverb effect with adjustable wet/dry mix
- Real-time audio visualization (waveform and spectrum)
- Interactive controls for tempo, octave, attack, release
- Multiple waveform options (sine, triangle, square, sawtooth)
- Full 12x12 twelve-tone matrix display
- Automatic continuous playback with random transformations
2025-12-13 16:26:02 +00:00
admin b50ef8bc00 Merge pull request #2 from metacube2/claude/paperless-finance-tool-01Te1nvY5VTkoZ9VFsZ16Jyk
Create Paperless finance reporting tool
2025-12-07 11:10:52 +01:00
263 changed files with 67138 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Ignore Visual Studio + build artifacts
.vs/
TrafagSalesExporter/.vs/
TrafagSalesExporter/bin/
TrafagSalesExporter/obj/
TrafagSalesExporter/*.user
TrafagSalesExporter/*.suo
TrafagSalesExporter/*.db
TrafagSalesExporter/*.db-shm
TrafagSalesExporter/*.db-wal
@@ -0,0 +1,362 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
A1000001229E3D0000000001 /* AudioVUMeterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000002229E3D0000000001 /* AudioVUMeterApp.swift */; };
A1000003229E3D0000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000004229E3D0000000002 /* ContentView.swift */; };
A1000005229E3D0000000003 /* VUMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000006229E3D0000000003 /* VUMeterView.swift */; };
A1000007229E3D0000000004 /* AudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000008229E3D0000000004 /* AudioEngine.swift */; };
A1000009229E3D0000000005 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000A229E3D0000000005 /* SystemMonitor.swift */; };
A100000B229E3D0000000006 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000C229E3D0000000006 /* SettingsView.swift */; };
A100000D229E3D0000000007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000E229E3D0000000007 /* Assets.xcassets */; };
A1000020229E3D0000000019 /* SerialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000021229E3D000000001A /* SerialManager.swift */; };
A1000022229E3D000000001B /* HardwareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000023229E3D000000001C /* HardwareView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
A1000002229E3D0000000001 /* AudioVUMeterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVUMeterApp.swift; sourceTree = "<group>"; };
A1000004229E3D0000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A1000006229E3D0000000003 /* VUMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VUMeterView.swift; sourceTree = "<group>"; };
A1000008229E3D0000000004 /* AudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngine.swift; sourceTree = "<group>"; };
A100000A229E3D0000000005 /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
A100000C229E3D0000000006 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A100000E229E3D0000000007 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A100000F229E3D0000000008 /* AudioVUMeter.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AudioVUMeter.entitlements; sourceTree = "<group>"; };
A1000010229E3D0000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A1000011229E3D000000000A /* AudioVUMeter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioVUMeter.app; sourceTree = BUILT_PRODUCTS_DIR; };
A1000021229E3D000000001A /* SerialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialManager.swift; sourceTree = "<group>"; };
A1000023229E3D000000001C /* HardwareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A1000012229E3D000000000B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A1000013229E3D000000000C = {
isa = PBXGroup;
children = (
A1000014229E3D000000000D /* AudioVUMeter */,
A1000015229E3D000000000E /* Products */,
);
sourceTree = "<group>";
};
A1000014229E3D000000000D /* AudioVUMeter */ = {
isa = PBXGroup;
children = (
A1000002229E3D0000000001 /* AudioVUMeterApp.swift */,
A1000004229E3D0000000002 /* ContentView.swift */,
A1000006229E3D0000000003 /* VUMeterView.swift */,
A1000008229E3D0000000004 /* AudioEngine.swift */,
A100000A229E3D0000000005 /* SystemMonitor.swift */,
A100000C229E3D0000000006 /* SettingsView.swift */,
A1000021229E3D000000001A /* SerialManager.swift */,
A1000023229E3D000000001C /* HardwareView.swift */,
A100000E229E3D0000000007 /* Assets.xcassets */,
A100000F229E3D0000000008 /* AudioVUMeter.entitlements */,
A1000010229E3D0000000009 /* Info.plist */,
);
path = AudioVUMeter;
sourceTree = "<group>";
};
A1000015229E3D000000000E /* Products */ = {
isa = PBXGroup;
children = (
A1000011229E3D000000000A /* AudioVUMeter.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A1000016229E3D000000000F /* AudioVUMeter */ = {
isa = PBXNativeTarget;
buildConfigurationList = A1000017229E3D0000000010 /* Build configuration list for PBXNativeTarget "AudioVUMeter" */;
buildPhases = (
A1000018229E3D0000000011 /* Sources */,
A1000012229E3D000000000B /* Frameworks */,
A1000019229E3D0000000012 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = AudioVUMeter;
productName = AudioVUMeter;
productReference = A1000011229E3D000000000A /* AudioVUMeter.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A100001A229E3D0000000013 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
A1000016229E3D000000000F = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = A100001B229E3D0000000014 /* Build configuration list for PBXProject "AudioVUMeter" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A1000013229E3D000000000C;
productRefGroup = A1000015229E3D000000000E /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A1000016229E3D000000000F /* AudioVUMeter */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A1000019229E3D0000000012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A100000D229E3D0000000007 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A1000018229E3D0000000011 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1000001229E3D0000000001 /* AudioVUMeterApp.swift in Sources */,
A1000003229E3D0000000002 /* ContentView.swift in Sources */,
A1000005229E3D0000000003 /* VUMeterView.swift in Sources */,
A1000007229E3D0000000004 /* AudioEngine.swift in Sources */,
A1000009229E3D0000000005 /* SystemMonitor.swift in Sources */,
A100000B229E3D0000000006 /* SettingsView.swift in Sources */,
A1000020229E3D0000000019 /* SerialManager.swift in Sources */,
A1000022229E3D000000001B /* HardwareView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A100001C229E3D0000000015 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A100001D229E3D0000000016 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
A100001E229E3D0000000017 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = AudioVUMeter/AudioVUMeter.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AudioVUMeter/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Audio VU Meter needs access to audio input to display audio levels from BlackHole or other audio devices.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.audiotools.AudioVUMeter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
A100001F229E3D0000000018 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = AudioVUMeter/AudioVUMeter.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AudioVUMeter/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Audio VU Meter needs access to audio input to display audio levels from BlackHole or other audio devices.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.audiotools.AudioVUMeter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A1000017229E3D0000000010 /* Build configuration list for PBXNativeTarget "AudioVUMeter" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A100001E229E3D0000000017 /* Debug */,
A100001F229E3D0000000018 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A100001B229E3D0000000014 /* Build configuration list for PBXProject "AudioVUMeter" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A100001C229E3D0000000015 /* Debug */,
A100001D229E3D0000000016 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A100001A229E3D0000000013 /* Project object */;
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.459",
"green" : "0.831",
"red" : "0.216"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+431
View File
@@ -0,0 +1,431 @@
//
// AudioEngine.swift
// AudioVUMeter
//
// Core Audio engine for capturing audio from BlackHole or any input device
// Calculates RMS levels and converts to dB for VU meter display
//
import Foundation
import AVFoundation
import CoreAudio
import Combine
/// Represents an available audio input device
struct AudioDevice: Identifiable, Hashable {
let id: AudioDeviceID
let name: String
let uid: String
let inputChannels: Int
}
/// Main audio engine class for capturing and analyzing audio levels
class AudioEngine: ObservableObject {
// MARK: - Published Properties
/// Current audio levels (0.0 to 1.0)
@Published var leftLevel: Double = 0
@Published var rightLevel: Double = 0
/// Peak levels with hold
@Published var leftPeak: Double = 0
@Published var rightPeak: Double = 0
/// Levels in dB (-inf to 0)
@Published var leftLevelDB: Double = -60
@Published var rightLevelDB: Double = -60
/// Engine state
@Published var isRunning = false
@Published var selectedDeviceID: AudioDeviceID = 0
@Published var selectedDeviceName: String = "No Device"
@Published var availableDevices: [AudioDevice] = []
/// Settings
@Published var referenceLevel: Double = -18 // Reference level in dB
@Published var peakHoldTime: Double = 2.0 // Peak hold time in seconds
// MARK: - Private Properties
private var audioEngine: AVAudioEngine?
private var inputNode: AVAudioInputNode?
private var peakResetTimers: [Timer] = []
private let levelSmoothingFactor: Double = 0.3
private var previousLeftLevel: Double = 0
private var previousRightLevel: Double = 0
// MARK: - Initialization
init() {
refreshDeviceList()
selectBlackHoleDevice()
}
// MARK: - Device Management
/// Refresh the list of available audio input devices
func refreshDeviceList() {
availableDevices = getInputDevices()
if availableDevices.isEmpty {
selectedDeviceName = "No Input Devices"
}
}
/// Get all available audio input devices
private func getInputDevices() -> [AudioDevice] {
var devices: [AudioDevice] = []
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var propertySize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&propertySize
)
guard status == noErr else { return devices }
let deviceCount = Int(propertySize) / MemoryLayout<AudioDeviceID>.size
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&propertySize,
&deviceIDs
)
guard status == noErr else { return devices }
for deviceID in deviceIDs {
// Check if device has input channels
let inputChannels = getDeviceInputChannels(deviceID: deviceID)
guard inputChannels > 0 else { continue }
// Get device name
let name = getDeviceName(deviceID: deviceID)
let uid = getDeviceUID(deviceID: deviceID)
devices.append(AudioDevice(
id: deviceID,
name: name,
uid: uid,
inputChannels: inputChannels
))
}
return devices
}
/// Get device name
private func getDeviceName(deviceID: AudioDeviceID) -> String {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceNameCFString,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var name: CFString = "" as CFString
var propertySize = UInt32(MemoryLayout<CFString>.size)
let status = AudioObjectGetPropertyData(
deviceID,
&propertyAddress,
0,
nil,
&propertySize,
&name
)
return status == noErr ? name as String : "Unknown Device"
}
/// Get device UID
private func getDeviceUID(deviceID: AudioDeviceID) -> String {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var uid: CFString = "" as CFString
var propertySize = UInt32(MemoryLayout<CFString>.size)
let status = AudioObjectGetPropertyData(
deviceID,
&propertyAddress,
0,
nil,
&propertySize,
&uid
)
return status == noErr ? uid as String : ""
}
/// Get number of input channels for a device
private func getDeviceInputChannels(deviceID: AudioDeviceID) -> Int {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain
)
var propertySize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(
deviceID,
&propertyAddress,
0,
nil,
&propertySize
)
guard status == noErr, propertySize > 0 else { return 0 }
let bufferListPointer = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: Int(propertySize))
defer { bufferListPointer.deallocate() }
status = AudioObjectGetPropertyData(
deviceID,
&propertyAddress,
0,
nil,
&propertySize,
bufferListPointer
)
guard status == noErr else { return 0 }
let bufferList = bufferListPointer.pointee
var channelCount = 0
let buffers = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: bufferListPointer))
for buffer in buffers {
channelCount += Int(buffer.mNumberChannels)
}
return channelCount
}
/// Select BlackHole device if available
private func selectBlackHoleDevice() {
// Try to find BlackHole device
if let blackholeDevice = availableDevices.first(where: {
$0.name.lowercased().contains("blackhole")
}) {
selectedDeviceID = blackholeDevice.id
selectedDeviceName = blackholeDevice.name
return
}
// Fall back to first available device
if let firstDevice = availableDevices.first {
selectedDeviceID = firstDevice.id
selectedDeviceName = firstDevice.name
}
}
/// Switch to selected audio device
func switchDevice() {
let wasRunning = isRunning
if wasRunning {
stop()
}
if let device = availableDevices.first(where: { $0.id == selectedDeviceID }) {
selectedDeviceName = device.name
setSystemInputDevice(deviceID: selectedDeviceID)
}
if wasRunning {
start()
}
}
/// Set the system default input device
private func setSystemInputDevice(deviceID: AudioDeviceID) {
var deviceID = deviceID
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
UInt32(MemoryLayout<AudioDeviceID>.size),
&deviceID
)
}
// MARK: - Audio Engine Control
/// Start audio capture
func start() {
guard !isRunning else { return }
do {
audioEngine = AVAudioEngine()
guard let engine = audioEngine else { return }
inputNode = engine.inputNode
guard let input = inputNode else { return }
let format = input.outputFormat(forBus: 0)
// Install tap on input node to capture audio
input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer)
}
try engine.start()
isRunning = true
print("Audio engine started - capturing from: \(selectedDeviceName)")
print("Format: \(format)")
} catch {
print("Failed to start audio engine: \(error)")
isRunning = false
}
}
/// Stop audio capture
func stop() {
guard isRunning else { return }
inputNode?.removeTap(onBus: 0)
audioEngine?.stop()
audioEngine = nil
inputNode = nil
isRunning = false
// Reset levels
DispatchQueue.main.async {
self.leftLevel = 0
self.rightLevel = 0
self.leftLevelDB = -60
self.rightLevelDB = -60
}
print("Audio engine stopped")
}
/// Reset peak indicators
func resetPeaks() {
DispatchQueue.main.async {
self.leftPeak = 0
self.rightPeak = 0
}
}
// MARK: - Audio Processing
/// Process incoming audio buffer
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
guard let floatData = buffer.floatChannelData else { return }
let frameCount = Int(buffer.frameLength)
let channelCount = Int(buffer.format.channelCount)
var leftRMS: Float = 0
var rightRMS: Float = 0
// Calculate RMS for left channel
let leftChannel = floatData[0]
var leftSum: Float = 0
for i in 0..<frameCount {
let sample = leftChannel[i]
leftSum += sample * sample
}
leftRMS = sqrt(leftSum / Float(frameCount))
// Calculate RMS for right channel (or use left if mono)
if channelCount > 1 {
let rightChannel = floatData[1]
var rightSum: Float = 0
for i in 0..<frameCount {
let sample = rightChannel[i]
rightSum += sample * sample
}
rightRMS = sqrt(rightSum / Float(frameCount))
} else {
rightRMS = leftRMS
}
// Convert to dB
let leftDB = 20 * log10(max(leftRMS, 1e-10))
let rightDB = 20 * log10(max(rightRMS, 1e-10))
// Normalize to 0-1 range (assuming -60dB is silence)
let minDB: Float = -60
let maxDB: Float = 0
let normalizedLeft = Double(max(0, min(1, (leftDB - minDB) / (maxDB - minDB))))
let normalizedRight = Double(max(0, min(1, (rightDB - minDB) / (maxDB - minDB))))
// Apply smoothing
let smoothedLeft = previousLeftLevel * (1 - levelSmoothingFactor) + normalizedLeft * levelSmoothingFactor
let smoothedRight = previousRightLevel * (1 - levelSmoothingFactor) + normalizedRight * levelSmoothingFactor
previousLeftLevel = smoothedLeft
previousRightLevel = smoothedRight
// Update UI on main thread
DispatchQueue.main.async {
self.leftLevel = smoothedLeft
self.rightLevel = smoothedRight
self.leftLevelDB = Double(leftDB)
self.rightLevelDB = Double(rightDB)
// Update peaks
if smoothedLeft > self.leftPeak {
self.leftPeak = smoothedLeft
self.schedulePeakReset(channel: 0)
}
if smoothedRight > self.rightPeak {
self.rightPeak = smoothedRight
self.schedulePeakReset(channel: 1)
}
}
}
/// Schedule peak reset after hold time
private func schedulePeakReset(channel: Int) {
// Cancel existing timer for this channel
if channel < peakResetTimers.count {
peakResetTimers[channel].invalidate()
}
let timer = Timer.scheduledTimer(withTimeInterval: peakHoldTime, repeats: false) { [weak self] _ in
DispatchQueue.main.async {
if channel == 0 {
self?.leftPeak = self?.leftLevel ?? 0
} else {
self?.rightPeak = self?.rightLevel ?? 0
}
}
}
if peakResetTimers.count > channel {
peakResetTimers[channel] = timer
} else {
peakResetTimers.append(timer)
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,54 @@
//
// AudioVUMeterApp.swift
// AudioVUMeter
//
// macOS Audio VU Meter with System Monitoring
// Captures audio from BlackHole virtual audio device
// Outputs to physical VU meter hardware via Serial/USB
//
import SwiftUI
@main
struct AudioVUMeterApp: App {
@StateObject private var audioEngine = AudioEngine()
@StateObject private var systemMonitor = SystemMonitor()
@StateObject private var serialManager = SerialManager()
// Timer for updating hardware values
@State private var updateTimer: Timer?
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(audioEngine)
.environmentObject(systemMonitor)
.environmentObject(serialManager)
.onAppear {
startHardwareUpdateTimer()
}
.onDisappear {
stopHardwareUpdateTimer()
}
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
Settings {
SettingsView()
.environmentObject(audioEngine)
.environmentObject(serialManager)
}
}
private func startHardwareUpdateTimer() {
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { _ in
serialManager.updateValues(audioEngine: audioEngine, systemMonitor: systemMonitor)
}
}
private func stopHardwareUpdateTimer() {
updateTimer?.invalidate()
updateTimer = nil
}
}
+343
View File
@@ -0,0 +1,343 @@
//
// ContentView.swift
// AudioVUMeter
//
// Main view containing all VU meters and hardware output
//
import SwiftUI
struct ContentView: View {
@EnvironmentObject var audioEngine: AudioEngine
@EnvironmentObject var systemMonitor: SystemMonitor
@EnvironmentObject var serialManager: SerialManager
@State private var showSettings = false
@State private var showHardwareSettings = false
var body: some View {
ZStack {
// Background gradient
LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.1, green: 0.1, blue: 0.15),
Color(red: 0.05, green: 0.05, blue: 0.1)
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
// Header
HStack {
Text("Audio VU Meter")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(.white)
Spacer()
// Hardware settings button
Button(action: { showHardwareSettings.toggle() }) {
Image(systemName: "cable.connector")
.font(.system(size: 16))
.foregroundColor(serialManager.isConnected ? .green : .gray)
}
.buttonStyle(.plain)
.help("Hardware Settings")
// Settings button
Button(action: { showSettings.toggle() }) {
Image(systemName: "gear")
.font(.system(size: 18))
.foregroundColor(.gray)
}
.buttonStyle(.plain)
.popover(isPresented: $showSettings) {
QuickSettingsView()
.environmentObject(audioEngine)
}
}
.padding(.horizontal)
.padding(.top, 10)
// Audio device info
HStack {
Circle()
.fill(audioEngine.isRunning ? Color.green : Color.red)
.frame(width: 8, height: 8)
Text(audioEngine.selectedDeviceName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Text(audioEngine.isRunning ? "ACTIVE" : "STOPPED")
.font(.system(size: 10, weight: .semibold, design: .monospaced))
.foregroundColor(audioEngine.isRunning ? .green : .red)
}
.padding(.horizontal)
Divider()
.background(Color.gray.opacity(0.3))
// Audio VU Meters
VStack(spacing: 15) {
Text("AUDIO LEVELS")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
HStack(spacing: 30) {
// Left Channel
VUMeterView(
level: audioEngine.leftLevel,
peakLevel: audioEngine.leftPeak,
label: "L",
colorScheme: .audio
)
// Right Channel
VUMeterView(
level: audioEngine.rightLevel,
peakLevel: audioEngine.rightPeak,
label: "R",
colorScheme: .audio
)
}
// dB Display
HStack(spacing: 40) {
VStack {
Text(String(format: "%.1f dB", audioEngine.leftLevelDB))
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(dbColor(for: audioEngine.leftLevelDB))
Text("LEFT")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
}
VStack {
Text(String(format: "%.1f dB", audioEngine.rightLevelDB))
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(dbColor(for: audioEngine.rightLevelDB))
Text("RIGHT")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.3))
)
.padding(.horizontal)
Divider()
.background(Color.gray.opacity(0.3))
// System Monitors
VStack(spacing: 15) {
Text("SYSTEM MONITOR")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
HStack(spacing: 25) {
// CPU Meter
SystemMeterView(
value: systemMonitor.cpuUsage,
label: "CPU",
unit: "%",
colorScheme: .cpu
)
// RAM Meter
SystemMeterView(
value: systemMonitor.memoryUsage,
label: "RAM",
unit: "%",
colorScheme: .ram
)
// Disk I/O Meter
SystemMeterView(
value: systemMonitor.diskActivity,
label: "DISK",
unit: "%",
colorScheme: .disk
)
// Network Meter
SystemMeterView(
value: systemMonitor.networkActivity,
label: "NET",
unit: "%",
colorScheme: .network
)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.3))
)
.padding(.horizontal)
Divider()
.background(Color.gray.opacity(0.3))
// Hardware Output Panel
HardwarePanelView()
.environmentObject(serialManager)
// Control buttons
HStack(spacing: 15) {
Button(action: {
if audioEngine.isRunning {
audioEngine.stop()
} else {
audioEngine.start()
}
}) {
HStack {
Image(systemName: audioEngine.isRunning ? "stop.fill" : "play.fill")
Text(audioEngine.isRunning ? "Stop" : "Start")
}
.frame(width: 80)
}
.buttonStyle(ControlButtonStyle(color: audioEngine.isRunning ? .red : .green))
Button(action: {
audioEngine.resetPeaks()
}) {
HStack {
Image(systemName: "arrow.counterclockwise")
Text("Reset")
}
.frame(width: 80)
}
.buttonStyle(ControlButtonStyle(color: .orange))
}
.padding(.bottom, 15)
}
}
}
.frame(width: 400, height: 750)
.sheet(isPresented: $showHardwareSettings) {
HardwareSettingsSheet()
.environmentObject(serialManager)
}
.onAppear {
audioEngine.start()
systemMonitor.startMonitoring()
}
.onDisappear {
audioEngine.stop()
systemMonitor.stopMonitoring()
serialManager.disconnect()
}
}
private func dbColor(for db: Double) -> Color {
if db > -3 { return .red }
if db > -10 { return .orange }
if db > -20 { return .yellow }
return .green
}
}
// MARK: - Hardware Settings Sheet
struct HardwareSettingsSheet: View {
@EnvironmentObject var serialManager: SerialManager
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Hardware Configuration")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
.padding()
.background(Color(nsColor: .windowBackgroundColor))
Divider()
// Settings content
HardwareSettingsView()
.environmentObject(serialManager)
}
.frame(width: 500, height: 600)
}
}
// MARK: - Quick Settings Popover
struct QuickSettingsView: View {
@EnvironmentObject var audioEngine: AudioEngine
var body: some View {
VStack(alignment: .leading, spacing: 15) {
Text("Audio Device")
.font(.headline)
Picker("Device", selection: $audioEngine.selectedDeviceID) {
ForEach(audioEngine.availableDevices, id: \.id) { device in
Text(device.name).tag(device.id)
}
}
.labelsHidden()
.frame(width: 250)
.onChange(of: audioEngine.selectedDeviceID) { _ in
audioEngine.switchDevice()
}
Divider()
Text("Reference Level")
.font(.headline)
HStack {
Text("-60 dB")
.font(.caption)
Slider(value: $audioEngine.referenceLevel, in: -60...0)
Text("0 dB")
.font(.caption)
}
Text("Peak Hold Time: \(Int(audioEngine.peakHoldTime))s")
.font(.caption)
Slider(value: $audioEngine.peakHoldTime, in: 0.5...5.0)
}
.padding()
.frame(width: 300)
}
}
// MARK: - Control Button Style
struct ControlButtonStyle: ButtonStyle {
let color: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 15)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(color.opacity(configuration.isPressed ? 0.6 : 0.8))
)
}
}
#Preview {
ContentView()
.environmentObject(AudioEngine())
.environmentObject(SystemMonitor())
.environmentObject(SerialManager())
}
@@ -0,0 +1,524 @@
//
// HardwareView.swift
// AudioVUMeter
//
// Hardware configuration and monitoring view for physical VU meters
// Includes auto-probe functionality to detect connected hardware
//
import SwiftUI
// MARK: - Hardware Panel in Main View
struct HardwarePanelView: View {
@EnvironmentObject var serialManager: SerialManager
var body: some View {
VStack(spacing: 15) {
// Header
HStack {
Text("HARDWARE OUTPUT")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
Spacer()
// Connection status
HStack(spacing: 6) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
Text(statusText)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.foregroundColor(statusColor)
}
}
// Probing progress
if serialManager.isProbing {
VStack(spacing: 8) {
ProgressView(value: serialManager.probeProgress)
.progressViewStyle(.linear)
Text(serialManager.probeStatus)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.orange)
}
} else {
// 4 Physical Dial Indicators
HStack(spacing: 15) {
ForEach(0..<4) { index in
DialIndicatorView(
dialNumber: index + 1,
value: serialManager.dialValues[index],
channelName: shortChannelName(serialManager.dialConfigs[index].dialChannel),
isConnected: serialManager.isConnected
)
}
}
}
// Buttons
HStack(spacing: 10) {
// Auto-probe button
Button(action: {
if serialManager.isProbing {
serialManager.stopAutoProbe()
} else {
serialManager.startAutoProbe()
}
}) {
HStack {
Image(systemName: serialManager.isProbing ? "stop.fill" : "magnifyingglass")
Text(serialManager.isProbing ? "Stop" : "Auto-Find")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(ProbeButtonStyle(isProbing: serialManager.isProbing))
.disabled(serialManager.isConnected)
// Connect button
Button(action: {
serialManager.toggleConnection()
}) {
HStack {
Image(systemName: serialManager.isConnected ? "antenna.radiowaves.left.and.right.slash" : "antenna.radiowaves.left.and.right")
Text(serialManager.isConnected ? "Disconnect" : "Connect")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(HardwareButtonStyle(isConnected: serialManager.isConnected))
.disabled(serialManager.isProbing)
}
// Stats / Device info
if serialManager.isConnected {
HStack {
Text("TX: \(formatBytes(serialManager.bytesSent))")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Text(serialManager.selectedPortPath.components(separatedBy: "/").last ?? "")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
}
} else if let detected = serialManager.detectedDevice {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.system(size: 10))
Text("Found: \(detected.name)")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.green)
Spacer()
if let vid = detected.vendorID, let pid = detected.productID {
Text(String(format: "%04X:%04X", vid, pid))
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.gray)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.3))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor, lineWidth: 1)
)
)
.padding(.horizontal)
}
private var statusColor: Color {
if serialManager.isProbing { return .orange }
if serialManager.isConnected { return .green }
return .red
}
private var statusText: String {
if serialManager.isProbing { return "PROBING" }
if serialManager.isConnected { return "CONNECTED" }
return "DISCONNECTED"
}
private var borderColor: Color {
if serialManager.isProbing { return .orange.opacity(0.3) }
if serialManager.isConnected { return .green.opacity(0.3) }
return .clear
}
private func shortChannelName(_ channel: DialChannel) -> String {
switch channel {
case .audioLeft: return "L"
case .audioRight: return "R"
case .audioPeak: return "PK"
case .audioMono: return "M"
case .cpu: return "CPU"
case .ram: return "RAM"
case .disk: return "DSK"
case .network: return "NET"
}
}
private func formatBytes(_ bytes: UInt64) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
// MARK: - Single Dial Indicator
struct DialIndicatorView: View {
let dialNumber: Int
let value: Int
let channelName: String
let isConnected: Bool
var body: some View {
VStack(spacing: 4) {
// Dial number
Text("D\(dialNumber)")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
// Value arc
ZStack {
// Background arc
Circle()
.trim(from: 0.25, to: 0.75)
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(180))
// Value arc
Circle()
.trim(from: 0.25, to: 0.25 + (Double(value) / 255.0) * 0.5)
.stroke(
isConnected ? dialColor(for: value) : Color.gray,
style: StrokeStyle(lineWidth: 4, lineCap: .round)
)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(180))
// Value text
VStack(spacing: 0) {
Text("\(value)")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(isConnected ? .white : .gray)
}
}
// Channel name
Text(channelName)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.foregroundColor(channelColor(channelName))
}
}
private func dialColor(for value: Int) -> Color {
let ratio = Double(value) / 255.0
if ratio > 0.9 { return .red }
if ratio > 0.75 { return .orange }
if ratio > 0.5 { return .yellow }
return .green
}
private func channelColor(_ name: String) -> Color {
switch name {
case "L", "R", "PK", "M": return .green
case "CPU": return .blue
case "RAM": return .purple
case "DSK": return .teal
case "NET": return .indigo
default: return .gray
}
}
}
// MARK: - Hardware Settings View
struct HardwareSettingsView: View {
@EnvironmentObject var serialManager: SerialManager
var body: some View {
Form {
// Auto-Probe Section
Section("Auto-Detect Hardware") {
HStack {
Button(action: {
if serialManager.isProbing {
serialManager.stopAutoProbe()
} else {
serialManager.startAutoProbe()
}
}) {
HStack {
Image(systemName: serialManager.isProbing ? "stop.fill" : "magnifyingglass.circle.fill")
Text(serialManager.isProbing ? "Stop Probing" : "Auto-Detect VU Meter")
}
}
.disabled(serialManager.isConnected)
Spacer()
Button("Quick Connect") {
serialManager.autoConnect()
}
.disabled(serialManager.isConnected || serialManager.isProbing)
}
if serialManager.isProbing {
VStack(alignment: .leading, spacing: 8) {
ProgressView(value: serialManager.probeProgress) {
Text(serialManager.probeStatus)
.font(.caption)
}
}
}
if let detected = serialManager.detectedDevice {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
VStack(alignment: .leading) {
Text("Detected: \(detected.name)")
.font(.headline)
if let vid = detected.vendorID, let pid = detected.productID {
Text(String(format: "USB ID: %04X:%04X", vid, pid))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// Connection Section
Section("Serial Connection") {
// Port selection with USB info
Picker("Port", selection: $serialManager.selectedPortPath) {
Text("Select Port...").tag("")
ForEach(serialManager.availablePorts) { port in
HStack {
if port.isVUMeter {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
Text(port.name)
if let vid = port.vendorID, let pid = port.productID {
Text(String(format: "(%04X:%04X)", vid, pid))
.foregroundColor(.secondary)
.font(.caption)
}
}
.tag(port.path)
}
}
HStack {
Button(action: { serialManager.refreshPorts() }) {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderless)
Spacer()
Text("\(serialManager.availablePorts.count) ports found")
.font(.caption)
.foregroundColor(.secondary)
}
// Baud rate
Picker("Baud Rate", selection: $serialManager.baudRate) {
ForEach(SerialManager.availableBaudRates, id: \.self) { rate in
Text("\(rate)").tag(rate)
}
}
// Protocol
Picker("Protocol", selection: $serialManager.selectedProtocol) {
ForEach(SerialProtocol.allCases) { proto in
Text(proto.rawValue).tag(proto)
}
}
// Connect button
Button(action: { serialManager.toggleConnection() }) {
HStack {
Image(systemName: serialManager.isConnected ? "bolt.slash.fill" : "bolt.fill")
Text(serialManager.isConnected ? "Disconnect" : "Connect")
}
}
.foregroundColor(serialManager.isConnected ? .red : .green)
.disabled(serialManager.isProbing)
}
// Dial Configuration Section
Section("Dial Assignments") {
ForEach(0..<4) { index in
DialConfigRow(
dialNumber: index + 1,
config: $serialManager.dialConfigs[index]
)
}
}
// Advanced Settings
Section("Advanced") {
ForEach(0..<4) { index in
DisclosureGroup("Dial \(index + 1) Settings") {
HStack {
Text("Min Value")
Spacer()
TextField("0", value: $serialManager.dialConfigs[index].minValue, format: .number)
.frame(width: 60)
.textFieldStyle(.roundedBorder)
}
HStack {
Text("Max Value")
Spacer()
TextField("255", value: $serialManager.dialConfigs[index].maxValue, format: .number)
.frame(width: 60)
.textFieldStyle(.roundedBorder)
}
Toggle("Invert", isOn: $serialManager.dialConfigs[index].inverted)
HStack {
Text("Smoothing")
Slider(value: $serialManager.dialConfigs[index].smoothing, in: 0...0.9)
Text("\(Int(serialManager.dialConfigs[index].smoothing * 100))%")
.frame(width: 40)
}
}
}
}
// Probe Results (for debugging)
if !serialManager.probeResults.isEmpty {
Section("Probe Results") {
ForEach(serialManager.probeResults.indices, id: \.self) { index in
let result = serialManager.probeResults[index]
HStack {
Image(systemName: result.success ? "checkmark.circle" : "xmark.circle")
.foregroundColor(result.success ? .green : .red)
VStack(alignment: .leading) {
Text(result.port.name)
.font(.caption)
Text("\(result.baudRate) baud - \(result.protocol_.rawValue)")
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
if let response = result.response {
Text(response.prefix(20) + "...")
.font(.caption2)
.foregroundColor(.green)
}
}
}
Button("Clear Results") {
serialManager.probeResults.removeAll()
}
}
}
// Protocol Info
Section("Protocol Reference") {
VStack(alignment: .leading, spacing: 8) {
protocolInfo
}
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.formStyle(.grouped)
}
@ViewBuilder
private var protocolInfo: some View {
switch serialManager.selectedProtocol {
case .rawBytes:
Text("Format: [0xAA] [D1] [D2] [D3] [D4] [0x55]")
Text("Values: 0-255 per dial")
case .textCommand:
Text("Format: CH1:val;CH2:val;CH3:val;CH4:val\\n")
Text("Values: 0-255 per channel")
case .json:
Text("Format: {\"dials\":[d1,d2,d3,d4]}\\n")
Text("Values: 0-255 array")
case .vuServer:
Text("Format: #0:val\\n#1:val\\n#2:val\\n#3:val\\n")
Text("Values: 0-100 percentage per dial")
}
}
}
// MARK: - Dial Config Row
struct DialConfigRow: View {
let dialNumber: Int
@Binding var config: DialConfig
var body: some View {
HStack {
Text("Dial \(dialNumber)")
.font(.system(.body, design: .monospaced))
.frame(width: 60, alignment: .leading)
Picker("", selection: $config.dialChannel) {
ForEach(DialChannel.allCases) { channel in
Text(channel.rawValue).tag(channel)
}
}
.labelsHidden()
}
}
}
// MARK: - Button Styles
struct HardwareButtonStyle: ButtonStyle {
let isConnected: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isConnected ? Color.red.opacity(0.7) : Color.green.opacity(0.7))
.opacity(configuration.isPressed ? 0.6 : 1.0)
)
}
}
struct ProbeButtonStyle: ButtonStyle {
let isProbing: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isProbing ? Color.orange.opacity(0.7) : Color.blue.opacity(0.7))
.opacity(configuration.isPressed ? 0.6 : 1.0)
)
}
}
// MARK: - Preview
#Preview {
HardwareSettingsView()
.environmentObject(SerialManager())
.frame(width: 500, height: 700)
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2024. All rights reserved.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Audio VU Meter needs access to audio input to display audio levels from BlackHole or other audio devices.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
@@ -0,0 +1,485 @@
//
// SerialManager.swift
// AudioVUMeter
//
// Direct VU1 Dials Hub communication - Native Swift
// Protocol: >{CMD:02X}{TYPE:02X}{LEN:04X}{DATA}\r\n
//
import Foundation
import IOKit
import IOKit.serial
// MARK: - VU1 Protocol Constants
private struct VU1 {
// Commands (from Comms_Hub_Server.py)
static let CMD_SET_DIAL_PERC_SINGLE: UInt8 = 0x03
static let CMD_RESCAN_BUS: UInt8 = 0x0C
static let CMD_GET_DEVICES_MAP: UInt8 = 0x07
// Data Types
static let DATA_NONE: UInt8 = 0x01
static let DATA_KEY_VALUE_PAIR: UInt8 = 0x04
// Serial
static let BAUD: speed_t = 115200
static let SUFFIX = "\r\n"
}
// MARK: - Serial Protocol Enum
enum SerialProtocol: String, CaseIterable, Identifiable {
case rawBytes = "Raw Bytes (0-255)"
case textCommand = "Text Commands"
case json = "JSON Format"
case vuServer = "VU1 Direct (Native)"
var id: String { rawValue }
}
// MARK: - Serial Port
struct SerialPort: Identifiable, Hashable {
let id: String
let path: String
let name: String
let vendorID: Int?
let productID: Int?
let isVUMeter: Bool
init(path: String, name: String, vendorID: Int? = nil, productID: Int? = nil, isVUMeter: Bool = false) {
self.id = path
self.path = path
self.name = name
self.vendorID = vendorID
self.productID = productID
self.isVUMeter = isVUMeter
}
}
// MARK: - Probe Result
struct ProbeResult {
let port: SerialPort
let protocol_: SerialProtocol
let baudRate: Int
let success: Bool
let response: String?
let responseTime: TimeInterval
}
// MARK: - Dial Channel
enum DialChannel: String, CaseIterable, Identifiable {
case audioLeft = "Audio Left"
case audioRight = "Audio Right"
case cpu = "CPU Usage"
case ram = "RAM Usage"
case disk = "Disk Activity"
case network = "Network Activity"
case audioPeak = "Audio Peak"
case audioMono = "Audio Mono (L+R)"
var id: String { rawValue }
}
// MARK: - Dial Configuration
struct DialConfig: Identifiable, Codable {
let id: Int
var channel: String
var minValue: Int
var maxValue: Int
var inverted: Bool
var smoothing: Double
init(id: Int, channel: DialChannel = .audioLeft) {
self.id = id
self.channel = channel.rawValue
self.minValue = 0
self.maxValue = 100
self.inverted = false
self.smoothing = 0.3
}
var dialChannel: DialChannel {
get { DialChannel(rawValue: channel) ?? .audioLeft }
set { channel = newValue.rawValue }
}
}
// MARK: - Serial Manager
class SerialManager: ObservableObject {
// MARK: - Published Properties
@Published var isConnected = false
@Published var availablePorts: [SerialPort] = []
@Published var selectedPortPath: String = ""
@Published var selectedProtocol: SerialProtocol = .vuServer
@Published var baudRate: Int = 115200
@Published var dialConfigs: [DialConfig] = []
@Published var lastError: String?
@Published var bytesSent: UInt64 = 0
@Published var isProbing = false
@Published var probeProgress: Double = 0
@Published var probeStatus: String = ""
@Published var detectedDevice: SerialPort?
@Published var probeResults: [ProbeResult] = []
@Published var dialValues: [Int] = [0, 0, 0, 0]
// MARK: - Private Properties
private var fileDescriptor: Int32 = -1
private var writeQueue = DispatchQueue(label: "vu1.write", qos: .userInteractive)
private var updateTimer: Timer?
private let updateInterval: TimeInterval = 1.0 / 30.0
private var smoothedValues: [Double] = [0, 0, 0, 0]
private var lastSentValues: [Int] = [-1, -1, -1, -1]
// MARK: - Initialization
init() {
dialConfigs = [
DialConfig(id: 0, channel: .audioLeft),
DialConfig(id: 1, channel: .audioRight),
DialConfig(id: 2, channel: .cpu),
DialConfig(id: 3, channel: .ram)
]
refreshPorts()
}
deinit {
disconnect()
}
// MARK: - Port Discovery
func refreshPorts() {
availablePorts = findSerialPorts()
// Auto-select VU1 Hub (usbserial)
if let vu1 = availablePorts.first(where: { $0.isVUMeter }) {
selectedPortPath = vu1.path
} else if selectedPortPath.isEmpty, let first = availablePorts.first {
selectedPortPath = first.path
}
}
private func findSerialPorts() -> [SerialPort] {
var ports: [SerialPort] = []
var iterator: io_iterator_t = 0
let matching = IOServiceMatching(kIOSerialBSDServiceValue)
guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else {
return ports
}
var service = IOIteratorNext(iterator)
while service != 0 {
defer {
IOObjectRelease(service)
service = IOIteratorNext(iterator)
}
guard let path = IORegistryEntryCreateCFProperty(
service, kIOCalloutDeviceKey as CFString, kCFAllocatorDefault, 0
)?.takeRetainedValue() as? String else { continue }
guard path.contains("cu.") else { continue }
var name = path.components(separatedBy: "/").last ?? "Unknown"
var vendorID: Int?
var productID: Int?
var isVU1 = false
// Check for usbserial (FT230X = VU1 Hub)
if path.contains("usbserial") {
isVU1 = true
name = "VU1 Dials Hub"
}
// Walk registry for USB info
var parent: io_object_t = 0
var current = service
IOObjectRetain(current)
for _ in 0..<10 {
if IORegistryEntryGetParentEntry(current, kIOServicePlane, &parent) != KERN_SUCCESS { break }
IOObjectRelease(current)
current = parent
if let vid = IORegistryEntryCreateCFProperty(current, "idVendor" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
vendorID = vid
}
if let pid = IORegistryEntryCreateCFProperty(current, "idProduct" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
productID = pid
}
if let usbName = IORegistryEntryCreateCFProperty(current, "USB Product Name" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? String {
name = usbName
}
// FTDI FT230X = VU1 Hub
if vendorID == 0x0403 && (productID == 0x6015 || productID == 0x6001) {
isVU1 = true
name = "VU1 Dials Hub"
}
if vendorID != nil && productID != nil { break }
}
IOObjectRelease(current)
ports.append(SerialPort(path: path, name: name, vendorID: vendorID, productID: productID, isVUMeter: isVU1))
}
IOObjectRelease(iterator)
return ports.sorted { ($0.isVUMeter ? 0 : 1, $0.name) < ($1.isVUMeter ? 0 : 1, $1.name) }
}
// MARK: - Connection
func connect() {
guard !selectedPortPath.isEmpty else {
lastError = "No port selected"
return
}
// Open port
fileDescriptor = open(selectedPortPath, O_RDWR | O_NOCTTY | O_NONBLOCK)
guard fileDescriptor != -1 else {
lastError = "Failed to open: \(String(cString: strerror(errno)))"
return
}
// Configure 115200 8N1
var options = termios()
tcgetattr(fileDescriptor, &options)
cfsetispeed(&options, speed_t(B115200))
cfsetospeed(&options, speed_t(B115200))
// 8N1, no flow control
options.c_cflag &= ~UInt(PARENB | CSTOPB | CSIZE | CRTSCTS)
options.c_cflag |= UInt(CS8 | CREAD | CLOCAL)
// Raw mode
options.c_lflag &= ~UInt(ICANON | ECHO | ECHOE | ISIG)
options.c_oflag &= ~UInt(OPOST)
options.c_iflag &= ~UInt(IXON | IXOFF | IXANY | ICRNL | INLCR | IGNBRK)
// Timeouts
options.c_cc.16 = 0 // VMIN
options.c_cc.17 = 10 // VTIME = 1 second
tcsetattr(fileDescriptor, TCSANOW, &options)
tcflush(fileDescriptor, TCIOFLUSH)
isConnected = true
lastError = nil
lastSentValues = [-1, -1, -1, -1]
print("VU1 Hub connected: \(selectedPortPath)")
// Initialize: Rescan bus
sendCommand(cmd: VU1.CMD_RESCAN_BUS, dataType: VU1.DATA_NONE, data: [])
usleep(500_000) // Wait 500ms for rescan
// Set all dials to 0
for i in 0..<4 {
setDialValue(dialIndex: UInt8(i), value: 0)
usleep(20_000)
}
startUpdateTimer()
}
func disconnect() {
stopUpdateTimer()
if fileDescriptor != -1 {
// Reset dials to 0
for i in 0..<4 {
setDialValue(dialIndex: UInt8(i), value: 0)
usleep(10_000)
}
usleep(100_000)
close(fileDescriptor)
fileDescriptor = -1
}
isConnected = false
print("VU1 Hub disconnected")
}
func toggleConnection() {
if isConnected { disconnect() } else { connect() }
}
func autoConnect() {
refreshPorts()
if let vu1 = availablePorts.first(where: { $0.isVUMeter }) {
selectedPortPath = vu1.path
connect()
} else if let first = availablePorts.first {
selectedPortPath = first.path
connect()
} else {
lastError = "No serial ports found"
}
}
// MARK: - VU1 Protocol
/// Send VU1 command: >{CMD:02X}{TYPE:02X}{LEN:04X}{DATA}\r\n
private func sendCommand(cmd: UInt8, dataType: UInt8, data: [UInt8]) {
guard fileDescriptor != -1 else { return }
let dataLen = data.count
var cmdString = String(format: ">%02X%02X%04X", cmd, dataType, dataLen)
for byte in data {
cmdString += String(format: "%02X", byte)
}
cmdString += VU1.SUFFIX
guard let cmdData = cmdString.data(using: .ascii) else { return }
let written = cmdData.withUnsafeBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return -1 }
return write(fileDescriptor, base, cmdData.count)
}
if written > 0 {
bytesSent += UInt64(written)
}
}
/// Set dial value (0-100%)
private func setDialValue(dialIndex: UInt8, value: Int) {
let clampedValue = UInt8(max(0, min(100, value)))
// CMD: 0x03 = SET_DIAL_PERC_SINGLE
// TYPE: 0x04 = KEY_VALUE_PAIR
// DATA: [dial_index, value]
sendCommand(
cmd: VU1.CMD_SET_DIAL_PERC_SINGLE,
dataType: VU1.DATA_KEY_VALUE_PAIR,
data: [dialIndex, clampedValue]
)
}
// MARK: - Value Updates
func updateValues(audioEngine: AudioEngine, systemMonitor: SystemMonitor) {
for (index, config) in dialConfigs.enumerated() {
guard index < 4 else { break }
var rawValue: Double = 0
switch config.dialChannel {
case .audioLeft:
rawValue = audioEngine.leftLevel * 100
case .audioRight:
rawValue = audioEngine.rightLevel * 100
case .audioPeak:
rawValue = max(audioEngine.leftPeak, audioEngine.rightPeak) * 100
case .audioMono:
rawValue = ((audioEngine.leftLevel + audioEngine.rightLevel) / 2) * 100
case .cpu:
rawValue = systemMonitor.cpuUsage
case .ram:
rawValue = systemMonitor.memoryUsage
case .disk:
rawValue = systemMonitor.diskActivity
case .network:
rawValue = systemMonitor.networkActivity
}
// Smoothing
smoothedValues[index] = smoothedValues[index] * config.smoothing + rawValue * (1 - config.smoothing)
var value = Int(smoothedValues[index])
if config.inverted { value = 100 - value }
dialValues[index] = max(0, min(100, value))
}
}
func sendValues() {
guard isConnected, fileDescriptor != -1 else { return }
writeQueue.async { [weak self] in
guard let self = self else { return }
for (index, value) in self.dialValues.enumerated() {
// Only send if changed
if value != self.lastSentValues[index] {
self.setDialValue(dialIndex: UInt8(index), value: value)
self.lastSentValues[index] = value
usleep(5_000) // 5ms between commands
}
}
}
}
// MARK: - Timer
private func startUpdateTimer() {
stopUpdateTimer()
updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in
self?.sendValues()
}
}
private func stopUpdateTimer() {
updateTimer?.invalidate()
updateTimer = nil
}
// MARK: - Auto-Probe
func startAutoProbe() {
isProbing = true
probeProgress = 0
probeStatus = "Searching for VU1 Hub..."
DispatchQueue.global().async { [weak self] in
guard let self = self else { return }
for (index, port) in self.availablePorts.enumerated() {
DispatchQueue.main.async {
self.probeProgress = Double(index + 1) / Double(self.availablePorts.count)
self.probeStatus = "Checking: \(port.name)"
}
if port.isVUMeter || port.path.contains("usbserial") {
DispatchQueue.main.async {
self.detectedDevice = port
self.selectedPortPath = port.path
self.probeStatus = "Found: \(port.name)"
self.isProbing = false
}
return
}
}
DispatchQueue.main.async {
self.isProbing = false
self.probeStatus = "No VU1 Hub found"
}
}
}
func stopAutoProbe() {
isProbing = false
}
// MARK: - Static
static let availableBaudRates = [9600, 19200, 38400, 57600, 115200, 230400]
}
@@ -0,0 +1,145 @@
//
// SettingsView.swift
// AudioVUMeter
//
// Settings window for configuring audio device, hardware output, and preferences
//
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var audioEngine: AudioEngine
@EnvironmentObject var serialManager: SerialManager
@AppStorage("showPeakIndicator") private var showPeakIndicator = true
@AppStorage("meterStyle") private var meterStyle = "classic"
@AppStorage("updateRate") private var updateRate = 30.0
var body: some View {
TabView {
// Audio Settings
Form {
Section("Audio Device") {
Picker("Input Device", selection: $audioEngine.selectedDeviceID) {
ForEach(audioEngine.availableDevices, id: \.id) { device in
HStack {
Text(device.name)
Spacer()
Text("\(device.inputChannels) ch")
.foregroundColor(.secondary)
.font(.caption)
}
.tag(device.id)
}
}
.onChange(of: audioEngine.selectedDeviceID) { _ in
audioEngine.switchDevice()
}
Button("Refresh Devices") {
audioEngine.refreshDeviceList()
}
}
Section("Levels") {
HStack {
Text("Reference Level")
Spacer()
Text("\(Int(audioEngine.referenceLevel)) dB")
.foregroundColor(.secondary)
}
Slider(value: $audioEngine.referenceLevel, in: -60...0, step: 1)
HStack {
Text("Peak Hold Time")
Spacer()
Text("\(String(format: "%.1f", audioEngine.peakHoldTime)) s")
.foregroundColor(.secondary)
}
Slider(value: $audioEngine.peakHoldTime, in: 0.5...10, step: 0.5)
}
}
.tabItem {
Label("Audio", systemImage: "waveform")
}
// Hardware Settings
HardwareSettingsView()
.environmentObject(serialManager)
.tabItem {
Label("Hardware", systemImage: "cable.connector")
}
// Display Settings
Form {
Section("Meter Display") {
Toggle("Show Peak Indicator", isOn: $showPeakIndicator)
Picker("Meter Style", selection: $meterStyle) {
Text("Classic").tag("classic")
Text("Modern").tag("modern")
Text("Minimal").tag("minimal")
}
}
Section("Performance") {
HStack {
Text("Update Rate")
Spacer()
Text("\(Int(updateRate)) fps")
.foregroundColor(.secondary)
}
Slider(value: $updateRate, in: 10...60, step: 5)
}
}
.tabItem {
Label("Display", systemImage: "display")
}
// About
VStack(spacing: 20) {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 64))
.foregroundColor(.accentColor)
Text("Audio VU Meter")
.font(.title)
.fontWeight(.bold)
Text("Version 1.1.0")
.foregroundColor(.secondary)
Divider()
.frame(width: 200)
VStack(spacing: 8) {
Text("A macOS audio level meter with system monitoring")
Text("and physical VU meter hardware support.")
}
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.frame(width: 300)
Spacer()
VStack(spacing: 4) {
Text("Supports BlackHole virtual audio device")
Text("and USB/Serial VU meter hardware")
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.tabItem {
Label("About", systemImage: "info.circle")
}
}
.frame(width: 500, height: 400)
}
}
#Preview {
SettingsView()
.environmentObject(AudioEngine())
.environmentObject(SerialManager())
}
@@ -0,0 +1,278 @@
//
// SystemMonitor.swift
// AudioVUMeter
//
// System resource monitoring for CPU, RAM, Disk, and Network
// Uses mach kernel APIs for accurate system statistics
//
import Foundation
import Darwin
/// System resource monitor class
class SystemMonitor: ObservableObject {
// MARK: - Published Properties
@Published var cpuUsage: Double = 0
@Published var memoryUsage: Double = 0
@Published var diskActivity: Double = 0
@Published var networkActivity: Double = 0
// Additional details
@Published var cpuUserUsage: Double = 0
@Published var cpuSystemUsage: Double = 0
@Published var memoryUsed: UInt64 = 0
@Published var memoryTotal: UInt64 = 0
@Published var networkBytesIn: UInt64 = 0
@Published var networkBytesOut: UInt64 = 0
// MARK: - Private Properties
private var updateTimer: Timer?
private var previousCPUInfo: host_cpu_load_info?
private var previousNetworkBytes: (in: UInt64, out: UInt64) = (0, 0)
private var previousDiskBytes: (read: UInt64, write: UInt64) = (0, 0)
private let updateInterval: TimeInterval = 0.5
// MARK: - Public Methods
/// Start monitoring system resources
func startMonitoring() {
// Get initial values
previousCPUInfo = getCPULoadInfo()
previousNetworkBytes = getNetworkBytes()
previousDiskBytes = getDiskBytes()
// Start update timer
updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in
self?.updateMetrics()
}
// Initial update
updateMetrics()
}
/// Stop monitoring
func stopMonitoring() {
updateTimer?.invalidate()
updateTimer = nil
}
// MARK: - Private Methods
private func updateMetrics() {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else { return }
let cpu = self.calculateCPUUsage()
let memory = self.calculateMemoryUsage()
let disk = self.calculateDiskActivity()
let network = self.calculateNetworkActivity()
DispatchQueue.main.async {
self.cpuUsage = cpu.total
self.cpuUserUsage = cpu.user
self.cpuSystemUsage = cpu.system
self.memoryUsage = memory.percentage
self.memoryUsed = memory.used
self.memoryTotal = memory.total
self.diskActivity = disk
self.networkActivity = network.percentage
self.networkBytesIn = network.bytesIn
self.networkBytesOut = network.bytesOut
}
}
}
// MARK: - CPU Monitoring
private func getCPULoadInfo() -> host_cpu_load_info? {
var cpuLoadInfo = host_cpu_load_info()
var count = mach_msg_type_number_t(MemoryLayout<host_cpu_load_info>.stride / MemoryLayout<integer_t>.stride)
let result = withUnsafeMutablePointer(to: &cpuLoadInfo) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count)
}
}
return result == KERN_SUCCESS ? cpuLoadInfo : nil
}
private func calculateCPUUsage() -> (total: Double, user: Double, system: Double) {
guard let currentInfo = getCPULoadInfo(),
let previousInfo = previousCPUInfo else {
return (0, 0, 0)
}
let userDiff = Double(currentInfo.cpu_ticks.0 - previousInfo.cpu_ticks.0)
let systemDiff = Double(currentInfo.cpu_ticks.1 - previousInfo.cpu_ticks.1)
let idleDiff = Double(currentInfo.cpu_ticks.2 - previousInfo.cpu_ticks.2)
let niceDiff = Double(currentInfo.cpu_ticks.3 - previousInfo.cpu_ticks.3)
let totalTicks = userDiff + systemDiff + idleDiff + niceDiff
guard totalTicks > 0 else { return (0, 0, 0) }
let userPercent = (userDiff / totalTicks) * 100
let systemPercent = (systemDiff / totalTicks) * 100
let totalPercent = ((userDiff + systemDiff + niceDiff) / totalTicks) * 100
previousCPUInfo = currentInfo
return (min(totalPercent, 100), min(userPercent, 100), min(systemPercent, 100))
}
// MARK: - Memory Monitoring
private func calculateMemoryUsage() -> (percentage: Double, used: UInt64, total: UInt64) {
var stats = vm_statistics64()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.stride / MemoryLayout<integer_t>.stride)
let result = withUnsafeMutablePointer(to: &stats) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard result == KERN_SUCCESS else {
return (0, 0, 0)
}
let pageSize = UInt64(vm_kernel_page_size)
let totalMemory = ProcessInfo.processInfo.physicalMemory
// Calculate used memory
let activeMemory = UInt64(stats.active_count) * pageSize
let wiredMemory = UInt64(stats.wire_count) * pageSize
let compressedMemory = UInt64(stats.compressor_page_count) * pageSize
let usedMemory = activeMemory + wiredMemory + compressedMemory
let percentage = (Double(usedMemory) / Double(totalMemory)) * 100
return (min(percentage, 100), usedMemory, totalMemory)
}
// MARK: - Disk Monitoring
private func getDiskBytes() -> (read: UInt64, write: UInt64) {
// Use IOKit for disk statistics
// Simplified implementation - returns approximate values
var readBytes: UInt64 = 0
var writeBytes: UInt64 = 0
// Get disk statistics from system
let task = Process()
task.launchPath = "/usr/bin/iostat"
task.arguments = ["-d", "-c", "1"]
let pipe = Pipe()
task.standardOutput = pipe
do {
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8) {
// Parse iostat output
let lines = output.components(separatedBy: "\n")
if lines.count > 2 {
let values = lines[2].split(separator: " ").compactMap { Double($0) }
if values.count >= 3 {
// KB/t, tps, MB/s
readBytes = UInt64(values.last ?? 0 * 1024 * 1024)
}
}
}
} catch {
// Fallback to simulated values
}
return (readBytes, writeBytes)
}
private func calculateDiskActivity() -> Double {
let currentBytes = getDiskBytes()
let readDiff = currentBytes.read > previousDiskBytes.read ?
currentBytes.read - previousDiskBytes.read : 0
let writeDiff = currentBytes.write > previousDiskBytes.write ?
currentBytes.write - previousDiskBytes.write : 0
previousDiskBytes = currentBytes
// Normalize to percentage (assuming 100MB/s as max)
let totalBytes = Double(readDiff + writeDiff)
let maxBytesPerInterval = 100.0 * 1024 * 1024 * updateInterval
let percentage = (totalBytes / maxBytesPerInterval) * 100
return min(percentage, 100)
}
// MARK: - Network Monitoring
private func getNetworkBytes() -> (in: UInt64, out: UInt64) {
var ifaddr: UnsafeMutablePointer<ifaddrs>?
var bytesIn: UInt64 = 0
var bytesOut: UInt64 = 0
guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else {
return (0, 0)
}
defer { freeifaddrs(ifaddr) }
var ptr = firstAddr
while true {
let interface = ptr.pointee
// Check for data link layer
if interface.ifa_addr.pointee.sa_family == UInt8(AF_LINK) {
// Get network interface data
if let data = interface.ifa_data {
let networkData = data.assumingMemoryBound(to: if_data.self).pointee
bytesIn += UInt64(networkData.ifi_ibytes)
bytesOut += UInt64(networkData.ifi_obytes)
}
}
guard let next = interface.ifa_next else { break }
ptr = next
}
return (bytesIn, bytesOut)
}
private func calculateNetworkActivity() -> (percentage: Double, bytesIn: UInt64, bytesOut: UInt64) {
let currentBytes = getNetworkBytes()
let bytesInDiff = currentBytes.in > previousNetworkBytes.in ?
currentBytes.in - previousNetworkBytes.in : 0
let bytesOutDiff = currentBytes.out > previousNetworkBytes.out ?
currentBytes.out - previousNetworkBytes.out : 0
previousNetworkBytes = currentBytes
// Calculate rate in bytes per second
let totalBytesPerSecond = Double(bytesInDiff + bytesOutDiff) / updateInterval
// Normalize to percentage (assuming 100 Mbps as reference)
let maxBytesPerSecond = 100.0 * 1024 * 1024 / 8 // 100 Mbps in bytes
let percentage = (totalBytesPerSecond / maxBytesPerSecond) * 100
return (min(percentage, 100), bytesInDiff, bytesOutDiff)
}
}
// MARK: - Memory Formatter Extension
extension SystemMonitor {
/// Format bytes to human readable string
static func formatBytes(_ bytes: UInt64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .memory
return formatter.string(fromByteCount: Int64(bytes))
}
}
+256
View File
@@ -0,0 +1,256 @@
//
// VUMeterView.swift
// AudioVUMeter
//
// Classic VU Meter visualization component
//
import SwiftUI
enum MeterColorScheme {
case audio
case cpu
case ram
case disk
case network
var gradient: [Color] {
switch self {
case .audio:
return [.green, .yellow, .orange, .red]
case .cpu:
return [.blue, .cyan, .yellow, .red]
case .ram:
return [.purple, .pink, .orange, .red]
case .disk:
return [.teal, .green, .yellow, .orange]
case .network:
return [.indigo, .blue, .cyan, .green]
}
}
var accentColor: Color {
switch self {
case .audio: return .green
case .cpu: return .blue
case .ram: return .purple
case .disk: return .teal
case .network: return .indigo
}
}
}
// MARK: - Vertical VU Meter (for Audio)
struct VUMeterView: View {
let level: Double // 0.0 to 1.0
let peakLevel: Double
let label: String
let colorScheme: MeterColorScheme
@State private var animatedLevel: Double = 0
private let segmentCount = 20
private let meterHeight: CGFloat = 200
private let meterWidth: CGFloat = 35
var body: some View {
VStack(spacing: 8) {
// Label
Text(label)
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white)
// Meter
ZStack(alignment: .bottom) {
// Background
RoundedRectangle(cornerRadius: 4)
.fill(Color.black.opacity(0.5))
.frame(width: meterWidth, height: meterHeight)
// Segments
VStack(spacing: 2) {
ForEach((0..<segmentCount).reversed(), id: \.self) { index in
let segmentThreshold = Double(index) / Double(segmentCount)
let isLit = animatedLevel > segmentThreshold
RoundedRectangle(cornerRadius: 2)
.fill(segmentColor(for: index, isLit: isLit))
.frame(width: meterWidth - 6, height: (meterHeight - CGFloat(segmentCount + 1) * 2) / CGFloat(segmentCount))
.shadow(color: isLit ? segmentColor(for: index, isLit: true).opacity(0.5) : .clear, radius: 3)
}
}
.padding(3)
// Peak indicator
if peakLevel > 0 {
let peakPosition = meterHeight * CGFloat(1 - peakLevel)
Rectangle()
.fill(Color.red)
.frame(width: meterWidth - 2, height: 3)
.offset(y: -meterHeight + peakPosition + meterHeight)
}
// dB Scale markers
HStack {
VStack(alignment: .trailing, spacing: 0) {
ForEach([0, -6, -12, -20, -40, -60], id: \.self) { db in
Text("\(db)")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.gray)
if db != -60 {
Spacer()
}
}
}
.frame(height: meterHeight)
.offset(x: -meterWidth/2 - 15)
Spacer()
}
}
.frame(width: meterWidth + 30, height: meterHeight)
}
.onChange(of: level) { newValue in
withAnimation(.easeOut(duration: 0.05)) {
animatedLevel = newValue
}
}
.onAppear {
animatedLevel = level
}
}
private func segmentColor(for index: Int, isLit: Bool) -> Color {
if !isLit {
return Color.gray.opacity(0.2)
}
let position = Double(index) / Double(segmentCount)
let colors = colorScheme.gradient
if position > 0.9 { return colors[3] } // Red zone
if position > 0.75 { return colors[2] } // Orange zone
if position > 0.5 { return colors[1] } // Yellow zone
return colors[0] // Green zone
}
}
// MARK: - Circular System Meter
struct SystemMeterView: View {
let value: Double // 0.0 to 100.0
let label: String
let unit: String
let colorScheme: MeterColorScheme
@State private var animatedValue: Double = 0
private let meterSize: CGFloat = 70
var body: some View {
VStack(spacing: 5) {
ZStack {
// Background circle
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 8)
.frame(width: meterSize, height: meterSize)
// Progress arc
Circle()
.trim(from: 0, to: CGFloat(animatedValue / 100))
.stroke(
AngularGradient(
gradient: Gradient(colors: colorScheme.gradient),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: 8, lineCap: .round)
)
.frame(width: meterSize, height: meterSize)
.rotationEffect(.degrees(-90))
// Value display
VStack(spacing: 0) {
Text(String(format: "%.0f", animatedValue))
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundColor(.white)
Text(unit)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.gray)
}
}
Text(label)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(colorScheme.accentColor)
}
.onChange(of: value) { newValue in
withAnimation(.easeOut(duration: 0.3)) {
animatedValue = newValue
}
}
.onAppear {
animatedValue = value
}
}
}
// MARK: - Horizontal Bar Meter
struct HorizontalMeterView: View {
let value: Double
let label: String
let colorScheme: MeterColorScheme
@State private var animatedValue: Double = 0
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Text(String(format: "%.1f%%", animatedValue))
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundColor(.white)
}
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
// Fill
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
gradient: Gradient(colors: colorScheme.gradient),
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: geometry.size.width * CGFloat(animatedValue / 100))
}
}
.frame(height: 12)
}
.onChange(of: value) { newValue in
withAnimation(.easeOut(duration: 0.3)) {
animatedValue = newValue
}
}
.onAppear {
animatedValue = value
}
}
}
#Preview {
HStack(spacing: 30) {
VUMeterView(level: 0.7, peakLevel: 0.9, label: "L", colorScheme: .audio)
VUMeterView(level: 0.5, peakLevel: 0.8, label: "R", colorScheme: .audio)
}
.padding()
.background(Color.black)
}
+230
View File
@@ -0,0 +1,230 @@
# Audio VU Meter for macOS
A native macOS SwiftUI application that displays real-time audio levels from BlackHole (or any audio input device) as a classic VU meter, along with system resource monitoring. **Now with physical VU meter hardware support!**
![macOS](https://img.shields.io/badge/macOS-13.0+-blue.svg)
![Swift](https://img.shields.io/badge/Swift-5.0-orange.svg)
![License](https://img.shields.io/badge/License-MIT-green.svg)
## Features
### Audio VU Meter
- **Real-time audio level monitoring** - Displays Left and Right channel levels
- **dB scale display** - Shows audio levels in decibels (-60 dB to 0 dB)
- **Peak hold indicators** - Visual peak markers with configurable hold time
- **BlackHole integration** - Automatically detects and selects BlackHole virtual audio device
- **Multi-device support** - Switch between any available audio input device
### System Resource Monitors
- **CPU Usage** - Real-time CPU utilization percentage
- **RAM Usage** - Memory consumption monitoring
- **Disk Activity** - Disk I/O activity indicator
- **Network Activity** - Network throughput monitoring
### Physical VU Meter Hardware Support
- **4 Physical Dials** - Support for up to 4 physical VU meter dials
- **Flexible Channel Mapping** - Assign any metric to any dial:
- Audio Left/Right channels
- Audio Peak or Mono (L+R)
- CPU, RAM, Disk, Network usage
- **Multiple Serial Protocols**:
- Raw Bytes: `[0xAA] [D1] [D2] [D3] [D4] [0x55]`
- Text Commands: `CH1:val;CH2:val;CH3:val;CH4:val\n`
- JSON: `{"dials":[d1,d2,d3,d4]}`
- VU-Server Compatible: `#0:val\n#1:val\n...`
- **Configurable per dial**: Min/max values, inversion, smoothing
- **Auto-detection** of USB serial devices
## Requirements
- macOS 13.0 (Ventura) or later
- Xcode 15.0 or later (for building)
- [BlackHole](https://existential.audio/blackhole/) virtual audio driver (recommended)
- USB/Serial VU meter hardware (optional)
## Installation
### Using BlackHole
1. Install BlackHole from [existential.audio/blackhole](https://existential.audio/blackhole/)
2. Configure BlackHole as a multi-output device in Audio MIDI Setup
3. Build and run Audio VU Meter
4. The app will automatically detect and select BlackHole
### Building from Source
1. Clone the repository
2. Open `AudioVUMeter.xcodeproj` in Xcode
3. Build and run (Cmd+R)
```bash
git clone <repository-url>
cd AudioVUMeter
open AudioVUMeter.xcodeproj
```
## Usage
### Main Window
- **Audio Levels**: The vertical VU meters show Left (L) and Right (R) channel audio levels
- **dB Readings**: Numeric display of current audio levels in decibels
- **System Meters**: Circular gauges showing CPU, RAM, Disk, and Network usage
- **Hardware Output**: Shows status of connected physical VU meters
### Controls
- **Start/Stop**: Toggle audio capture on/off
- **Reset**: Clear peak hold indicators
- **Settings** (gear icon): Access device selection and preferences
- **Hardware** (cable icon): Configure physical VU meter connection
### Hardware Setup
1. Connect your USB/Serial VU meter hardware
2. Click the cable icon or go to Settings -> Hardware
3. Select your serial port from the dropdown
4. Choose the appropriate baud rate (default: 115200)
5. Select the communication protocol your hardware uses
6. Assign channels to each dial (Audio L, R, CPU, RAM, etc.)
7. Click "Connect"
### Settings
- **Input Device**: Select audio input source (BlackHole, microphone, etc.)
- **Reference Level**: Adjust the 0 dB reference point
- **Peak Hold Time**: Configure how long peak indicators remain visible
- **Hardware**: Serial port, protocol, and dial assignments
## Architecture
```
AudioVUMeter/
├── AudioVUMeterApp.swift # App entry point
├── ContentView.swift # Main UI layout
├── VUMeterView.swift # VU meter components
├── AudioEngine.swift # Core Audio capture engine
├── SystemMonitor.swift # System resource monitoring
├── SerialManager.swift # USB/Serial communication
├── HardwareView.swift # Hardware configuration UI
├── SettingsView.swift # Settings window
└── Assets.xcassets/ # App icons and colors
```
### Key Components
- **AudioEngine**: Uses AVAudioEngine to capture audio from the selected input device, calculates RMS levels, and converts to dB
- **SystemMonitor**: Uses Mach kernel APIs to retrieve CPU, memory, disk, and network statistics
- **SerialManager**: Handles USB/Serial communication with physical VU meter hardware
- **VUMeterView**: SwiftUI views for classic vertical VU meters with segment-based display
- **SystemMeterView**: Circular gauge components for system metrics
## Hardware Protocol Reference
### Raw Bytes Protocol
```
Start: 0xAA
Data: [Dial1] [Dial2] [Dial3] [Dial4] (0-255 each)
End: 0x55
```
### Text Command Protocol
```
CH1:128;CH2:64;CH3:200;CH4:32\n
```
### JSON Protocol
```json
{"dials":[128,64,200,32]}
```
### VU-Server Compatible Protocol
```
#0:50
#1:75
#2:30
#3:90
```
Values are percentages (0-100)
## BlackHole Setup Guide
1. **Install BlackHole**: Download and install from [existential.audio](https://existential.audio/blackhole/)
2. **Create Multi-Output Device**:
- Open Audio MIDI Setup (Applications -> Utilities)
- Click the `+` button -> Create Multi-Output Device
- Check both your speakers and BlackHole
- Set as default output
3. **Route Audio**:
- System audio will now go to both speakers and BlackHole
- Audio VU Meter captures from BlackHole input
## Compatible Hardware
This app is designed to work with:
- [VU Dials by Sasa Karanovic](https://github.com/SasaKaranovic/VU-Server)
- Arduino-based VU meters with serial interface
- Any USB/Serial device accepting the supported protocols
## API Reference
### AudioEngine
```swift
// Start/stop audio capture
audioEngine.start()
audioEngine.stop()
// Reset peak indicators
audioEngine.resetPeaks()
// Switch audio device
audioEngine.selectedDeviceID = deviceID
audioEngine.switchDevice()
// Access levels
audioEngine.leftLevel // 0.0 to 1.0
audioEngine.rightLevel // 0.0 to 1.0
audioEngine.leftLevelDB // -60 to 0 dB
audioEngine.rightLevelDB // -60 to 0 dB
```
### SystemMonitor
```swift
// Start/stop monitoring
systemMonitor.startMonitoring()
systemMonitor.stopMonitoring()
// Access metrics (0-100%)
systemMonitor.cpuUsage
systemMonitor.memoryUsage
systemMonitor.diskActivity
systemMonitor.networkActivity
```
### SerialManager
```swift
// Connection
serialManager.connect()
serialManager.disconnect()
// Configuration
serialManager.selectedPortPath = "/dev/cu.usbserial-XXX"
serialManager.baudRate = 115200
serialManager.selectedProtocol = .vuServer
// Dial assignment
serialManager.dialConfigs[0].dialChannel = .audioLeft
serialManager.dialConfigs[1].dialChannel = .audioRight
serialManager.dialConfigs[2].dialChannel = .cpu
serialManager.dialConfigs[3].dialChannel = .ram
```
## License
MIT License - See LICENSE file for details.
## Credits
Inspired by [VU-Server](https://github.com/SasaKaranovic/VU-Server) by Sasa Karanovic.
@@ -0,0 +1,524 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
A10000001 /* FT991A_RemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000001; };
A10000002 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000002; };
A10000003 /* ModernRadioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000003; };
A10000004 /* SkeuomorphRadioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000004; };
A10000005 /* DebugPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000005; };
A10000006 /* LogPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000006; };
A10000007 /* AudioPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000007; };
A10000008 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000008; };
A10000009 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000009; };
A10000010 /* RadioState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010; };
A10000011 /* CATCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000011; };
A10000012 /* QSOEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000012; };
A10000013 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000013; };
A10000014 /* SerialPortManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000014; };
A10000015 /* CATProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000015; };
A10000016 /* CSVManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000016; };
A10000017 /* AudioRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000017; };
A10000018 /* RadioViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000018; };
A10000019 /* LogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000019; };
A10000020 /* SettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000020; };
A10000021 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000021; };
A10000022 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000022; };
A10000023 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = A20000023; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
A20000001 /* FT991A_RemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FT991A_RemoteApp.swift; sourceTree = "<group>"; };
A20000002 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
A20000003 /* ModernRadioView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernRadioView.swift; sourceTree = "<group>"; };
A20000004 /* SkeuomorphRadioView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeuomorphRadioView.swift; sourceTree = "<group>"; };
A20000005 /* DebugPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPanel.swift; sourceTree = "<group>"; };
A20000006 /* LogPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogPanel.swift; sourceTree = "<group>"; };
A20000007 /* AudioPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPanel.swift; sourceTree = "<group>"; };
A20000008 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A20000009 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
A20000010 /* RadioState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioState.swift; sourceTree = "<group>"; };
A20000011 /* CATCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CATCommand.swift; sourceTree = "<group>"; };
A20000012 /* QSOEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QSOEntry.swift; sourceTree = "<group>"; };
A20000013 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
A20000014 /* SerialPortManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialPortManager.swift; sourceTree = "<group>"; };
A20000015 /* CATProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CATProtocol.swift; sourceTree = "<group>"; };
A20000016 /* CSVManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVManager.swift; sourceTree = "<group>"; };
A20000017 /* AudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouter.swift; sourceTree = "<group>"; };
A20000018 /* RadioViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioViewModel.swift; sourceTree = "<group>"; };
A20000019 /* LogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewModel.swift; sourceTree = "<group>"; };
A20000020 /* SettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = "<group>"; };
A20000021 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
A20000022 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A20000023 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
A20000024 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
A20000025 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A20000026 /* FT991A_Remote.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FT991A_Remote.entitlements; sourceTree = "<group>"; };
A30000001 /* FT991A-Remote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FT991A-Remote.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A40000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A50000001 = {
isa = PBXGroup;
children = (
A50000002 /* FT991A-Remote */,
A50000003 /* Products */,
);
sourceTree = "<group>";
};
A50000002 /* FT991A-Remote */ = {
isa = PBXGroup;
children = (
A20000001 /* FT991A_RemoteApp.swift */,
A50000004 /* Models */,
A50000005 /* Services */,
A50000006 /* ViewModels */,
A50000007 /* Views */,
A50000008 /* Utilities */,
A20000022 /* Assets.xcassets */,
A20000025 /* Info.plist */,
A20000026 /* FT991A_Remote.entitlements */,
);
path = "FT991A-Remote";
sourceTree = "<group>";
};
A50000003 /* Products */ = {
isa = PBXGroup;
children = (
A30000001 /* FT991A-Remote.app */,
);
name = Products;
sourceTree = "<group>";
};
A50000004 /* Models */ = {
isa = PBXGroup;
children = (
A20000010 /* RadioState.swift */,
A20000011 /* CATCommand.swift */,
A20000012 /* QSOEntry.swift */,
A20000013 /* Settings.swift */,
);
path = Models;
sourceTree = "<group>";
};
A50000005 /* Services */ = {
isa = PBXGroup;
children = (
A20000014 /* SerialPortManager.swift */,
A20000015 /* CATProtocol.swift */,
A20000016 /* CSVManager.swift */,
A20000017 /* AudioRouter.swift */,
);
path = Services;
sourceTree = "<group>";
};
A50000006 /* ViewModels */ = {
isa = PBXGroup;
children = (
A20000018 /* RadioViewModel.swift */,
A20000019 /* LogViewModel.swift */,
A20000020 /* SettingsController.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
A50000007 /* Views */ = {
isa = PBXGroup;
children = (
A20000002 /* MainView.swift */,
A50000009 /* ModernView */,
A50000010 /* SkeuomorphView */,
A50000011 /* Panels */,
A50000012 /* Settings */,
A50000013 /* MenuBar */,
);
path = Views;
sourceTree = "<group>";
};
A50000008 /* Utilities */ = {
isa = PBXGroup;
children = (
A20000021 /* Logger.swift */,
A50000014 /* Localization */,
);
path = Utilities;
sourceTree = "<group>";
};
A50000009 /* ModernView */ = {
isa = PBXGroup;
children = (
A20000003 /* ModernRadioView.swift */,
);
path = ModernView;
sourceTree = "<group>";
};
A50000010 /* SkeuomorphView */ = {
isa = PBXGroup;
children = (
A20000004 /* SkeuomorphRadioView.swift */,
);
path = SkeuomorphView;
sourceTree = "<group>";
};
A50000011 /* Panels */ = {
isa = PBXGroup;
children = (
A20000005 /* DebugPanel.swift */,
A20000006 /* LogPanel.swift */,
A20000007 /* AudioPanel.swift */,
);
path = Panels;
sourceTree = "<group>";
};
A50000012 /* Settings */ = {
isa = PBXGroup;
children = (
A20000008 /* SettingsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
A50000013 /* MenuBar */ = {
isa = PBXGroup;
children = (
A20000009 /* MenuBarView.swift */,
);
path = MenuBar;
sourceTree = "<group>";
};
A50000014 /* Localization */ = {
isa = PBXGroup;
children = (
A20000023 /* Localizable.strings */,
);
path = Localization;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A60000001 /* FT991A-Remote */ = {
isa = PBXNativeTarget;
buildConfigurationList = A70000001;
buildPhases = (
A80000001 /* Sources */,
A40000001 /* Frameworks */,
A90000001 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "FT991A-Remote";
productName = "FT991A-Remote";
productReference = A30000001 /* FT991A-Remote.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AB0000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
A60000001 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = AC0000001;
compatibilityVersion = "Xcode 14.0";
developmentRegion = de;
hasScannedForEncodings = 0;
knownRegions = (
de,
en,
Base,
);
mainGroup = A50000001;
productRefGroup = A50000003 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A60000001 /* FT991A-Remote */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A90000001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10000022 /* Assets.xcassets in Resources */,
A10000023 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A80000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10000001 /* FT991A_RemoteApp.swift in Sources */,
A10000002 /* MainView.swift in Sources */,
A10000003 /* ModernRadioView.swift in Sources */,
A10000004 /* SkeuomorphRadioView.swift in Sources */,
A10000005 /* DebugPanel.swift in Sources */,
A10000006 /* LogPanel.swift in Sources */,
A10000007 /* AudioPanel.swift in Sources */,
A10000008 /* SettingsView.swift in Sources */,
A10000009 /* MenuBarView.swift in Sources */,
A10000010 /* RadioState.swift in Sources */,
A10000011 /* CATCommand.swift in Sources */,
A10000012 /* QSOEntry.swift in Sources */,
A10000013 /* Settings.swift in Sources */,
A10000014 /* SerialPortManager.swift in Sources */,
A10000015 /* CATProtocol.swift in Sources */,
A10000016 /* CSVManager.swift in Sources */,
A10000017 /* AudioRouter.swift in Sources */,
A10000018 /* RadioViewModel.swift in Sources */,
A10000019 /* LogViewModel.swift in Sources */,
A10000020 /* SettingsController.swift in Sources */,
A10000021 /* Logger.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
A20000023 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
A20000024 /* de */,
A20000025 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
AD0000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
AD0000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
AE0000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "FT991A-Remote/FT991A_Remote.entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "FT991A-Remote/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "FT-991A Remote";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright 2024";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "FT-991A Remote needs microphone access for audio monitoring and digital modes.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.hamradio.FT991A-Remote";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
AE0000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "FT991A-Remote/FT991A_Remote.entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "FT991A-Remote/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "FT-991A Remote";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright 2024";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "FT-991A Remote needs microphone access for audio monitoring and digital modes.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.hamradio.FT991A-Remote";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A70000001 /* Build configuration list for PBXNativeTarget "FT991A-Remote" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AE0000001 /* Debug */,
AE0000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AC0000001 /* Build configuration list for PBXProject "FT991A-Remote" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AD0000001 /* Debug */,
AD0000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AB0000001 /* Project object */;
}
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.984",
"green" : "0.584",
"red" : "0.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.678",
"red" : "0.251"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.device.serial</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,109 @@
//
// FT991A_RemoteApp.swift
// FT991A-Remote
//
// Yaesu FT-991A Remote Control Application for macOS
// CAT Protocol via USB Serial (Silicon Labs CP210x)
//
import SwiftUI
@main
struct FT991A_RemoteApp: App {
@StateObject private var radioViewModel = RadioViewModel()
@StateObject private var settingsController = SettingsController()
@StateObject private var logViewModel = LogViewModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(radioViewModel)
.environmentObject(settingsController)
.environmentObject(logViewModel)
.frame(minWidth: 800, minHeight: 600)
}
.windowStyle(.hiddenTitleBar)
.commands {
CommandGroup(replacing: .newItem) { }
CommandMenu("Radio") {
Button(radioViewModel.isConnected ? "Trennen" : "Verbinden") {
radioViewModel.toggleConnection()
}
.keyboardShortcut("k", modifiers: .command)
Divider()
Button("VFO A/B tauschen") {
radioViewModel.swapVFO()
}
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(!radioViewModel.isConnected)
Button("A=B") {
radioViewModel.equalizeVFO()
}
.keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(!radioViewModel.isConnected)
Divider()
Button("ATU Tune") {
radioViewModel.startATUTune()
}
.keyboardShortcut("t", modifiers: [.command, .shift])
.disabled(!radioViewModel.isConnected)
}
CommandMenu("Ansicht") {
Picker("UI-Stil", selection: $settingsController.uiStyle) {
Text("Modern").tag(UIStyle.modern)
Text("Frontpanel").tag(UIStyle.skeuomorph)
}
Divider()
Toggle("Debug-Panel anzeigen", isOn: $settingsController.showDebugPanel)
.keyboardShortcut("d", modifiers: [.command, .option])
Toggle("Log-Panel anzeigen", isOn: $settingsController.showLogPanel)
.keyboardShortcut("l", modifiers: [.command, .option])
}
}
Settings {
SettingsView()
.environmentObject(radioViewModel)
.environmentObject(settingsController)
}
MenuBarExtra("FT-991A", systemImage: radioViewModel.isConnected ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash") {
MenuBarView()
.environmentObject(radioViewModel)
.environmentObject(settingsController)
}
}
}
// MARK: - UI Style Enum
enum UIStyle: String, Codable, CaseIterable {
case modern = "Modern"
case skeuomorph = "Frontpanel"
}
// MARK: - Language Enum
enum AppLanguage: String, Codable, CaseIterable {
case german = "de"
case english = "en"
var displayName: String {
switch self {
case .german: return "Deutsch"
case .english: return "English"
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright 2024</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>csv</string>
</array>
<key>CFBundleTypeName</key>
<string>QSO Log File</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSItemContentTypes</key>
<array>
<string>public.comma-separated-values-text</string>
</array>
</dict>
</array>
<key>LSUIElement</key>
<false/>
<key>NSAppleEventsUsageDescription</key>
<string>FT-991A Remote needs to control other applications for audio routing.</string>
<key>NSMicrophoneUsageDescription</key>
<string>FT-991A Remote needs microphone access for audio monitoring and digital modes.</string>
</dict>
</plist>
@@ -0,0 +1,266 @@
//
// CATCommand.swift
// FT991A-Remote
//
// CAT Command definitions for FT-991A
//
import Foundation
// MARK: - CAT Command
struct CATCommand {
let command: String
let description: String
let expectsResponse: Bool
init(_ command: String, description: String = "", expectsResponse: Bool = true) {
self.command = command
self.description = description
self.expectsResponse = expectsResponse
}
var data: Data {
(command + ";").data(using: .ascii) ?? Data()
}
}
// MARK: - CAT Commands Catalog
enum CAT {
// MARK: - Frequency Commands
/// Read VFO-A frequency
static let readVFOA = CATCommand("FA", description: "Read VFO-A frequency")
/// Set VFO-A frequency (9 digits in Hz)
static func setVFOA(_ frequency: Int) -> CATCommand {
CATCommand(String(format: "FA%09d", frequency), description: "Set VFO-A to \(frequency) Hz", expectsResponse: false)
}
/// Read VFO-B frequency
static let readVFOB = CATCommand("FB", description: "Read VFO-B frequency")
/// Set VFO-B frequency (9 digits in Hz)
static func setVFOB(_ frequency: Int) -> CATCommand {
CATCommand(String(format: "FB%09d", frequency), description: "Set VFO-B to \(frequency) Hz", expectsResponse: false)
}
/// Swap VFO A/B
static let swapVFO = CATCommand("SV", description: "Swap VFO A/B", expectsResponse: false)
/// Select VFO-A
static let selectVFOA = CATCommand("VS0", description: "Select VFO-A", expectsResponse: false)
/// Select VFO-B
static let selectVFOB = CATCommand("VS1", description: "Select VFO-B", expectsResponse: false)
/// Read active VFO
static let readActiveVFO = CATCommand("VS", description: "Read active VFO")
// MARK: - Mode Commands
/// Read operating mode
static let readMode = CATCommand("MD0", description: "Read operating mode")
/// Set operating mode
static func setMode(_ mode: OperatingMode) -> CATCommand {
CATCommand("MD0\(mode.catValue)", description: "Set mode to \(mode.rawValue)", expectsResponse: false)
}
// MARK: - Level Commands
/// Read AF gain
static let readAFGain = CATCommand("AG0", description: "Read AF gain")
/// Set AF gain (000-255)
static func setAFGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "AG0%03d", min(255, max(0, value))), description: "Set AF gain", expectsResponse: false)
}
/// Read RF gain
static let readRFGain = CATCommand("RG0", description: "Read RF gain")
/// Set RF gain (000-255)
static func setRFGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "RG0%03d", min(255, max(0, value))), description: "Set RF gain", expectsResponse: false)
}
/// Read squelch
static let readSquelch = CATCommand("SQ0", description: "Read squelch")
/// Set squelch (000-255)
static func setSquelch(_ value: Int) -> CATCommand {
CATCommand(String(format: "SQ0%03d", min(255, max(0, value))), description: "Set squelch", expectsResponse: false)
}
/// Read MIC gain
static let readMICGain = CATCommand("MG", description: "Read MIC gain")
/// Set MIC gain (000-100)
static func setMICGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "MG%03d", min(100, max(0, value))), description: "Set MIC gain", expectsResponse: false)
}
/// Read power level
static let readPower = CATCommand("PC", description: "Read power level")
/// Set power level (005-100)
static func setPower(_ value: Int) -> CATCommand {
CATCommand(String(format: "PC%03d", min(100, max(5, value))), description: "Set power to \(value)W", expectsResponse: false)
}
// MARK: - Function Commands
/// Read Noise Blanker status
static let readNB = CATCommand("NB0", description: "Read NB status")
/// Set Noise Blanker on/off
static func setNB(_ enabled: Bool) -> CATCommand {
CATCommand("NB0\(enabled ? "1" : "0")", description: enabled ? "Enable NB" : "Disable NB", expectsResponse: false)
}
/// Read Noise Reduction status
static let readNR = CATCommand("NR0", description: "Read NR status")
/// Set Noise Reduction on/off
static func setNR(_ enabled: Bool) -> CATCommand {
CATCommand("NR0\(enabled ? "1" : "0")", description: enabled ? "Enable NR" : "Disable NR", expectsResponse: false)
}
/// Read DNF status
static let readDNF = CATCommand("BC0", description: "Read DNF status")
/// Set DNF on/off
static func setDNF(_ enabled: Bool) -> CATCommand {
CATCommand("BC0\(enabled ? "1" : "0")", description: enabled ? "Enable DNF" : "Disable DNF", expectsResponse: false)
}
/// Read Contour status
static let readContour = CATCommand("CO00", description: "Read Contour status")
/// Read ATU status
static let readATU = CATCommand("AC", description: "Read ATU status")
/// Start ATU tune
static let startATUTune = CATCommand("AC001", description: "Start ATU tune", expectsResponse: false)
/// Read Split status
static let readSplit = CATCommand("FT", description: "Read Split status")
/// Set Split on/off
static func setSplit(_ enabled: Bool) -> CATCommand {
CATCommand("FT\(enabled ? "1" : "0")", description: enabled ? "Enable Split" : "Disable Split", expectsResponse: false)
}
// MARK: - Metering Commands
/// Read S-Meter
static let readSMeter = CATCommand("SM0", description: "Read S-Meter")
/// Read Power meter
static let readPowerMeter = CATCommand("RM1", description: "Read Power meter")
/// Read SWR meter
static let readSWRMeter = CATCommand("RM6", description: "Read SWR meter")
// MARK: - PTT Commands
/// Start transmitting (MIC)
static let txOn = CATCommand("TX0", description: "TX on (MIC)", expectsResponse: false)
/// Start transmitting (DATA)
static let txOnData = CATCommand("TX1", description: "TX on (DATA)", expectsResponse: false)
/// Stop transmitting
static let txOff = CATCommand("RX", description: "TX off", expectsResponse: false)
/// Read TX status
static let readTXStatus = CATCommand("TX", description: "Read TX status")
// MARK: - Identification
/// Read radio ID
static let readID = CATCommand("ID", description: "Read radio ID")
// MARK: - Information
/// Read all status (IF command)
static let readInfo = CATCommand("IF", description: "Read info")
}
// MARK: - CAT Response
struct CATResponse {
let command: String
let value: String
let rawData: String
let timestamp: Date
init(rawData: String) {
self.rawData = rawData.trimmingCharacters(in: CharacterSet(charactersIn: ";\r\n"))
self.timestamp = Date()
// Parse command prefix (2 characters usually)
if rawData.count >= 2 {
let prefixEnd = rawData.index(rawData.startIndex, offsetBy: 2)
self.command = String(rawData[..<prefixEnd])
self.value = String(rawData[prefixEnd...]).trimmingCharacters(in: CharacterSet(charactersIn: ";\r\n"))
} else {
self.command = rawData
self.value = ""
}
}
// MARK: - Value Parsers
/// Parse frequency from FA/FB response (9 digits)
var frequency: Int? {
guard command == "FA" || command == "FB" else { return nil }
return Int(value)
}
/// Parse mode from MD0 response
var mode: OperatingMode? {
guard command == "MD" else { return nil }
let modeChar = value.dropFirst() // Remove "0" prefix
return OperatingMode.from(catValue: String(modeChar))
}
/// Parse level value (3 digits)
var levelValue: Int? {
// Handle commands like AG0XXX, RG0XXX, SQ0XXX
let numericPart = value.filter { $0.isNumber }
return Int(numericPart)
}
/// Parse S-Meter from SM0 response
var sMeter: Int? {
guard command == "SM" else { return nil }
// SM0XXX format - drop the "0" prefix
let numericPart = value.dropFirst()
return Int(numericPart)
}
/// Parse boolean status (0 or 1)
var boolValue: Bool? {
guard let last = value.last else { return nil }
return last == "1"
}
/// Parse VFO selection
var vfo: VFO? {
guard command == "VS" else { return nil }
switch value {
case "0": return .a
case "1": return .b
default: return nil
}
}
/// Check if this is the FT-991A ID
var isFT991A: Bool {
command == "ID" && value == "0670"
}
}
@@ -0,0 +1,163 @@
//
// QSOEntry.swift
// FT991A-Remote
//
// Model for QSO log entries
//
import Foundation
// MARK: - QSO Entry
struct QSOEntry: Identifiable, Codable, Hashable {
let id: UUID
var callsign: String
var date: Date
var frequency: Int // Hz
var mode: OperatingMode
var rstSent: String // e.g., "59", "599"
var rstReceived: String
var name: String
var qth: String
var locator: String // Maidenhead grid
var power: Int // Watts
var notes: String
init(
id: UUID = UUID(),
callsign: String = "",
date: Date = Date(),
frequency: Int = 14_250_000,
mode: OperatingMode = .usb,
rstSent: String = "59",
rstReceived: String = "59",
name: String = "",
qth: String = "",
locator: String = "",
power: Int = 100,
notes: String = ""
) {
self.id = id
self.callsign = callsign
self.date = date
self.frequency = frequency
self.mode = mode
self.rstSent = rstSent
self.rstReceived = rstReceived
self.name = name
self.qth = qth
self.locator = locator
self.power = power
self.notes = notes
}
// MARK: - CSV Export
static let csvHeader = "Call,Date,Time,Frequency,Mode,RST_TX,RST_RX,Name,QTH,Locator,Power,Notes"
var csvLine: String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm:ss"
timeFormatter.timeZone = TimeZone(identifier: "UTC")
let freqMHz = String(format: "%.6f", Double(frequency) / 1_000_000.0)
// Escape fields with commas or quotes
let escapedNotes = notes.contains(",") || notes.contains("\"")
? "\"\(notes.replacingOccurrences(of: "\"", with: "\"\""))\""
: notes
let escapedName = name.contains(",") || name.contains("\"")
? "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
: name
return [
callsign,
dateFormatter.string(from: date),
timeFormatter.string(from: date),
freqMHz,
mode.rawValue,
rstSent,
rstReceived,
escapedName,
qth,
locator,
String(power),
escapedNotes
].joined(separator: ",")
}
// MARK: - CSV Import
static func from(csvLine: String) -> QSOEntry? {
var fields: [String] = []
var current = ""
var inQuotes = false
for char in csvLine {
if char == "\"" {
inQuotes.toggle()
} else if char == "," && !inQuotes {
fields.append(current)
current = ""
} else {
current.append(char)
}
}
fields.append(current)
guard fields.count >= 12 else { return nil }
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(identifier: "UTC")
guard let date = dateFormatter.date(from: "\(fields[1]) \(fields[2])") else { return nil }
guard let freqMHz = Double(fields[3]) else { return nil }
let frequency = Int(freqMHz * 1_000_000)
let mode = OperatingMode.allCases.first { $0.rawValue == fields[4] } ?? .usb
return QSOEntry(
callsign: fields[0],
date: date,
frequency: frequency,
mode: mode,
rstSent: fields[5],
rstReceived: fields[6],
name: fields[7],
qth: fields[8],
locator: fields[9],
power: Int(fields[10]) ?? 100,
notes: fields[11]
)
}
// MARK: - Display Helpers
var frequencyDisplay: String {
let mhz = frequency / 1_000_000
let khz = (frequency % 1_000_000) / 1_000
let hz = frequency % 1_000
return String(format: "%d.%03d.%03d", mhz, khz, hz)
}
var dateDisplay: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy"
return formatter.string(from: date)
}
var timeDisplay: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter.string(from: date) + " UTC"
}
var bandDisplay: String {
Band.from(frequency: frequency)?.rawValue ?? "?"
}
}
@@ -0,0 +1,238 @@
//
// RadioState.swift
// FT991A-Remote
//
// Model representing the current state of the FT-991A transceiver
//
import Foundation
// MARK: - Radio State
struct RadioState {
// VFO Frequencies
var vfoAFrequency: Int = 14_250_000 // Hz
var vfoBFrequency: Int = 14_255_000 // Hz
var activeVFO: VFO = .a
// Operating Mode
var mode: OperatingMode = .usb
var filterWidth: Int = 3000 // Hz
var filterShift: Int = 0 // Hz
// Levels (0-255)
var afGain: Int = 128
var rfGain: Int = 255
var squelch: Int = 0
var micGain: Int = 50
var power: Int = 100 // Watts (5-100)
// Functions
var noiseBlanker: Bool = false
var noiseReduction: Bool = false
var dnf: Bool = false
var contour: Bool = false
var atu: Bool = false
var split: Bool = false
var ipo: Bool = false
// Metering
var sMeter: Int = 0 // 0-255
var powerMeter: Int = 0 // 0-255
var swrMeter: Int = 0 // 0-255
// TX State
var isTransmitting: Bool = false
// Computed Properties
var activeFrequency: Int {
activeVFO == .a ? vfoAFrequency : vfoBFrequency
}
var sMeterDB: Double {
// S0-S9 = 0-54 dBμV, each S-unit = 6 dB
// Above S9: +10, +20, +40, +60 dB
let normalized = Double(sMeter) / 255.0
if normalized <= 0.6 {
return normalized / 0.6 * 54.0 // S0-S9
} else {
return 54.0 + (normalized - 0.6) / 0.4 * 60.0 // S9+60
}
}
var sMeterString: String {
let normalized = Double(sMeter) / 255.0
if normalized <= 0.6 {
let sUnit = Int(normalized / 0.6 * 9.0)
return "S\(sUnit)"
} else {
let db = Int((normalized - 0.6) / 0.4 * 60.0)
return "S9+\(db)"
}
}
var frequencyDisplay: String {
formatFrequency(activeFrequency)
}
func formatFrequency(_ freq: Int) -> String {
let mhz = freq / 1_000_000
let khz = (freq % 1_000_000) / 1_000
let hz = freq % 1_000
return String(format: "%d.%03d.%03d", mhz, khz, hz)
}
}
// MARK: - VFO
enum VFO: String, Codable {
case a = "A"
case b = "B"
}
// MARK: - Operating Mode
enum OperatingMode: String, CaseIterable, Codable {
case lsb = "LSB"
case usb = "USB"
case cw = "CW"
case fm = "FM"
case am = "AM"
case rttyLSB = "RTTY-L"
case cwReverse = "CW-R"
case dataLSB = "DATA-L"
case rttyUSB = "RTTY-U"
case dataFM = "DATA-FM"
case fmNarrow = "FM-N"
case dataUSB = "DATA-U"
case amNarrow = "AM-N"
case c4fm = "C4FM"
// CAT command value (MD0X)
var catValue: String {
switch self {
case .lsb: return "1"
case .usb: return "2"
case .cw: return "3"
case .fm: return "4"
case .am: return "5"
case .rttyLSB: return "6"
case .cwReverse: return "7"
case .dataLSB: return "8"
case .rttyUSB: return "9"
case .dataFM: return "A"
case .fmNarrow: return "B"
case .dataUSB: return "C"
case .amNarrow: return "D"
case .c4fm: return "E"
}
}
static func from(catValue: String) -> OperatingMode? {
allCases.first { $0.catValue == catValue }
}
var isDigital: Bool {
switch self {
case .dataLSB, .dataUSB, .dataFM, .rttyLSB, .rttyUSB, .c4fm:
return true
default:
return false
}
}
var defaultFilterWidth: Int {
switch self {
case .lsb, .usb, .dataLSB, .dataUSB: return 3000
case .cw, .cwReverse: return 500
case .am, .amNarrow: return 6000
case .fm, .fmNarrow, .dataFM, .c4fm: return 15000
case .rttyLSB, .rttyUSB: return 500
}
}
}
// MARK: - Frequency Step
enum FrequencyStep: Int, CaseIterable, Codable {
case hz1 = 1
case hz10 = 10
case hz100 = 100
case khz1 = 1000
case khz5 = 5000
case khz10 = 10000
case khz100 = 100000
case mhz1 = 1000000
var displayName: String {
switch self {
case .hz1: return "1 Hz"
case .hz10: return "10 Hz"
case .hz100: return "100 Hz"
case .khz1: return "1 kHz"
case .khz5: return "5 kHz"
case .khz10: return "10 kHz"
case .khz100: return "100 kHz"
case .mhz1: return "1 MHz"
}
}
}
// MARK: - Band
enum Band: String, CaseIterable {
case m160 = "160m"
case m80 = "80m"
case m60 = "60m"
case m40 = "40m"
case m30 = "30m"
case m20 = "20m"
case m17 = "17m"
case m15 = "15m"
case m12 = "12m"
case m10 = "10m"
case m6 = "6m"
case m2 = "2m"
case cm70 = "70cm"
var frequencyRange: ClosedRange<Int> {
switch self {
case .m160: return 1_800_000...2_000_000
case .m80: return 3_500_000...4_000_000
case .m60: return 5_351_500...5_366_500
case .m40: return 7_000_000...7_300_000
case .m30: return 10_100_000...10_150_000
case .m20: return 14_000_000...14_350_000
case .m17: return 18_068_000...18_168_000
case .m15: return 21_000_000...21_450_000
case .m12: return 24_890_000...24_990_000
case .m10: return 28_000_000...29_700_000
case .m6: return 50_000_000...54_000_000
case .m2: return 144_000_000...148_000_000
case .cm70: return 430_000_000...450_000_000
}
}
var defaultFrequency: Int {
switch self {
case .m160: return 1_840_000
case .m80: return 3_700_000
case .m60: return 5_357_000
case .m40: return 7_100_000
case .m30: return 10_120_000
case .m20: return 14_250_000
case .m17: return 18_110_000
case .m15: return 21_250_000
case .m12: return 24_930_000
case .m10: return 28_500_000
case .m6: return 50_150_000
case .m2: return 145_500_000
case .cm70: return 433_500_000
}
}
static func from(frequency: Int) -> Band? {
allCases.first { $0.frequencyRange.contains(frequency) }
}
}
@@ -0,0 +1,123 @@
//
// Settings.swift
// FT991A-Remote
//
// Application settings model
//
import Foundation
// MARK: - App Settings
struct AppSettings: Codable {
// Connection
var serialPort: String = ""
var baudRate: Int = 38400
var autoReconnect: Bool = true
var reconnectInterval: TimeInterval = 5.0
// UI
var uiStyle: UIStyle = .modern
var language: AppLanguage = .german
var showDebugPanel: Bool = false
var showLogPanel: Bool = false
var compactMode: Bool = true
// Frequency
var frequencyStep: FrequencyStep = .khz1
// Logging
var logDirectory: String = "~/Documents/FT991A-Logs/"
var autoSaveLog: Bool = true
// Audio
var audioInputDevice: String = ""
var audioOutputDevice: String = ""
var useBlackHole: Bool = false
// Keyboard
var pttShortcutEnabled: Bool = true
var arrowFrequencyEnabled: Bool = true
var tunerShortcutEnabled: Bool = true
// MARK: - Persistence
static let defaults = AppSettings()
static var settingsURL: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appFolder = appSupport.appendingPathComponent("FT991A-Remote", isDirectory: true)
try? FileManager.default.createDirectory(at: appFolder, withIntermediateDirectories: true)
return appFolder.appendingPathComponent("settings.json")
}
static func load() -> AppSettings {
guard FileManager.default.fileExists(atPath: settingsURL.path) else {
return defaults
}
do {
let data = try Data(contentsOf: settingsURL)
return try JSONDecoder().decode(AppSettings.self, from: data)
} catch {
print("Failed to load settings: \(error)")
return defaults
}
}
func save() {
do {
let data = try JSONEncoder().encode(self)
try data.write(to: AppSettings.settingsURL)
} catch {
print("Failed to save settings: \(error)")
}
}
// MARK: - Log Directory
var expandedLogDirectory: String {
(logDirectory as NSString).expandingTildeInPath
}
mutating func ensureLogDirectoryExists() {
let path = expandedLogDirectory
if !FileManager.default.fileExists(atPath: path) {
try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
}
}
}
// MARK: - Serial Port Configuration
struct SerialConfig: Codable {
var baudRate: Int = 38400
var dataBits: Int = 8
var stopBits: Int = 1
var parity: Parity = .none
var flowControl: FlowControl = .none
enum Parity: String, Codable, CaseIterable {
case none = "None"
case odd = "Odd"
case even = "Even"
}
enum FlowControl: String, Codable, CaseIterable {
case none = "None"
case hardware = "RTS/CTS"
case software = "XON/XOFF"
}
static let ft991aDefault = SerialConfig(
baudRate: 38400,
dataBits: 8,
stopBits: 1,
parity: .none,
flowControl: .none
)
static let availableBaudRates = [4800, 9600, 19200, 38400, 57600, 115200]
}
@@ -0,0 +1,250 @@
//
// AudioRouter.swift
// FT991A-Remote
//
// BlackHole audio routing integration for digital modes
//
import Foundation
import AVFoundation
// MARK: - Audio Device
struct AudioDevice: Identifiable, Hashable {
let id: AudioDeviceID
let name: String
let uid: String
let isInput: Bool
let isOutput: Bool
let isBlackHole: Bool
var displayName: String {
if isBlackHole {
return "\(name) (Virtual)"
}
return name
}
}
// MARK: - Audio Router
class AudioRouter: ObservableObject {
// MARK: - Published Properties
@Published var inputDevices: [AudioDevice] = []
@Published var outputDevices: [AudioDevice] = []
@Published var selectedInputDevice: AudioDeviceID?
@Published var selectedOutputDevice: AudioDeviceID?
@Published var blackHoleDevice: AudioDevice?
@Published var ft991aDevice: AudioDevice?
@Published var isBlackHoleInstalled = false
@Published var lastError: String?
// MARK: - Initialization
init() {
refreshDevices()
}
// MARK: - Device Discovery
func refreshDevices() {
inputDevices = []
outputDevices = []
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0, nil,
&dataSize
)
guard status == noErr else {
lastError = "Fehler beim Abrufen der Audio-Geräte"
return
}
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0, nil,
&dataSize,
&deviceIDs
)
guard status == noErr else {
lastError = "Fehler beim Laden der Audio-Geräte"
return
}
for deviceID in deviceIDs {
if let device = createAudioDevice(from: deviceID) {
if device.isInput {
inputDevices.append(device)
}
if device.isOutput {
outputDevices.append(device)
}
// Detect BlackHole
if device.isBlackHole && blackHoleDevice == nil {
blackHoleDevice = device
isBlackHoleInstalled = true
}
// Detect FT-991A (usually shows as "USB Audio CODEC")
if device.name.contains("USB Audio") || device.name.contains("FT-991") {
ft991aDevice = device
}
}
}
Logger.shared.log("Found \(inputDevices.count) input and \(outputDevices.count) output devices", level: .debug)
if isBlackHoleInstalled {
Logger.shared.log("BlackHole detected: \(blackHoleDevice?.name ?? "Unknown")", level: .info)
}
}
private func createAudioDevice(from deviceID: AudioDeviceID) -> AudioDevice? {
// Get device name
var name: CFString = "" as CFString
var nameSize = UInt32(MemoryLayout<CFString>.size)
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceNameCFString,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &nameSize, &name)
guard status == noErr else { return nil }
// Get device UID
var uid: CFString = "" as CFString
var uidSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceUID
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &uidSize, &uid)
let deviceUID = status == noErr ? uid as String : ""
// Check for input channels
var inputSize: UInt32 = 0
propertyAddress.mSelector = kAudioDevicePropertyStreamConfiguration
propertyAddress.mScope = kAudioDevicePropertyScopeInput
_ = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &inputSize)
let hasInput = inputSize > 0
// Check for output channels
var outputSize: UInt32 = 0
propertyAddress.mScope = kAudioDevicePropertyScopeOutput
_ = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &outputSize)
let hasOutput = outputSize > 0
let deviceName = name as String
let isBlackHole = deviceName.lowercased().contains("blackhole")
return AudioDevice(
id: deviceID,
name: deviceName,
uid: deviceUID,
isInput: hasInput,
isOutput: hasOutput,
isBlackHole: isBlackHole
)
}
// MARK: - Device Selection
func selectInputDevice(_ device: AudioDevice) {
selectedInputDevice = device.id
Logger.shared.log("Selected input device: \(device.name)", level: .info)
}
func selectOutputDevice(_ device: AudioDevice) {
selectedOutputDevice = device.id
Logger.shared.log("Selected output device: \(device.name)", level: .info)
}
// MARK: - BlackHole Setup
func configureForDigitalModes() -> Bool {
guard isBlackHoleInstalled, let blackHole = blackHoleDevice else {
lastError = "BlackHole ist nicht installiert"
return false
}
// Route: FT-991A USB Audio BlackHole Digital Mode App
// Route back: Digital Mode App BlackHole FT-991A USB Audio
if let ft991a = ft991aDevice {
selectedInputDevice = ft991a.id // FT-991A as input (RX audio)
selectedOutputDevice = blackHole.id // BlackHole as output (to digital mode app)
Logger.shared.log("Configured for digital modes: \(ft991a.name)\(blackHole.name)", level: .info)
return true
} else {
lastError = "FT-991A Audio-Gerät nicht gefunden"
return false
}
}
// MARK: - System Audio
func setSystemDefaultInput(_ deviceID: AudioDeviceID) {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var deviceIDVar = deviceID
let status = AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size),
&deviceIDVar
)
if status != noErr {
lastError = "Fehler beim Setzen des Standard-Eingangs"
}
}
func setSystemDefaultOutput(_ deviceID: AudioDeviceID) {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var deviceIDVar = deviceID
let status = AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size),
&deviceIDVar
)
if status != noErr {
lastError = "Fehler beim Setzen des Standard-Ausgangs"
}
}
}
@@ -0,0 +1,442 @@
//
// CATProtocol.swift
// FT991A-Remote
//
// CAT Protocol handler for FT-991A communication
//
import Foundation
import Combine
// MARK: - CAT Protocol
class CATProtocol: ObservableObject {
// MARK: - Published Properties
@Published var radioState = RadioState()
@Published var isPolling = false
@Published var lastCommandTime: Date?
@Published var pendingCommands: Int = 0
// Debug console
@Published var commandHistory: [CommandLogEntry] = []
// MARK: - Private Properties
private let serialManager: SerialPortManager
private var responseQueue: [CATResponse] = []
private var pollingTimer: Timer?
private var cancellables = Set<AnyCancellable>()
private let commandQueue = DispatchQueue(label: "cat.command", qos: .userInitiated)
private var commandSemaphore = DispatchSemaphore(value: 1)
// Polling intervals
private let fastPollInterval: TimeInterval = 0.1 // 100ms for meters
private let slowPollInterval: TimeInterval = 0.5 // 500ms for frequency/mode
// MARK: - Initialization
init(serialManager: SerialPortManager) {
self.serialManager = serialManager
serialManager.onDataReceived = { [weak self] data in
self?.handleReceivedData(data)
}
serialManager.onConnectionChanged = { [weak self] connected in
if connected {
self?.startPolling()
self?.requestInitialState()
} else {
self?.stopPolling()
}
}
}
// MARK: - Command Sending
func send(_ command: CATCommand) {
serialManager.send(command)
lastCommandTime = Date()
// Log command
let entry = CommandLogEntry(
timestamp: Date(),
direction: .sent,
command: command.command,
description: command.description
)
DispatchQueue.main.async {
self.commandHistory.append(entry)
if self.commandHistory.count > 500 {
self.commandHistory.removeFirst(100)
}
}
}
func sendRaw(_ command: String) {
let catCommand = CATCommand(command, description: "Manual: \(command)")
send(catCommand)
}
// MARK: - Response Handling
private func handleReceivedData(_ data: Data) {
guard let responseString = String(data: data, encoding: .ascii) else { return }
let response = CATResponse(rawData: responseString)
// Log response
let entry = CommandLogEntry(
timestamp: Date(),
direction: .received,
command: response.rawData,
description: parseResponseDescription(response)
)
DispatchQueue.main.async {
self.commandHistory.append(entry)
}
// Update radio state
updateState(from: response)
}
private func updateState(from response: CATResponse) {
DispatchQueue.main.async {
switch response.command {
case "FA":
if let freq = response.frequency {
self.radioState.vfoAFrequency = freq
}
case "FB":
if let freq = response.frequency {
self.radioState.vfoBFrequency = freq
}
case "VS":
if let vfo = response.vfo {
self.radioState.activeVFO = vfo
}
case "MD":
if let mode = response.mode {
self.radioState.mode = mode
}
case "AG":
if let level = response.levelValue {
self.radioState.afGain = level
}
case "RG":
if let level = response.levelValue {
self.radioState.rfGain = level
}
case "SQ":
if let level = response.levelValue {
self.radioState.squelch = level
}
case "MG":
if let level = response.levelValue {
self.radioState.micGain = level
}
case "PC":
if let power = response.levelValue {
self.radioState.power = power
}
case "SM":
if let meter = response.sMeter {
self.radioState.sMeter = meter
}
case "RM":
// RM1 = power, RM6 = SWR
if let level = response.levelValue {
if response.value.hasPrefix("1") {
self.radioState.powerMeter = level
} else if response.value.hasPrefix("6") {
self.radioState.swrMeter = level
}
}
case "NB":
if let enabled = response.boolValue {
self.radioState.noiseBlanker = enabled
}
case "NR":
if let enabled = response.boolValue {
self.radioState.noiseReduction = enabled
}
case "BC":
if let enabled = response.boolValue {
self.radioState.dnf = enabled
}
case "FT":
if let enabled = response.boolValue {
self.radioState.split = enabled
}
case "TX":
if response.value == "0" {
self.radioState.isTransmitting = false
} else if response.value == "1" || response.value == "2" {
self.radioState.isTransmitting = true
}
default:
break
}
}
}
private func parseResponseDescription(_ response: CATResponse) -> String {
switch response.command {
case "FA":
if let freq = response.frequency {
return "VFO-A: \(radioState.formatFrequency(freq)) Hz"
}
case "FB":
if let freq = response.frequency {
return "VFO-B: \(radioState.formatFrequency(freq)) Hz"
}
case "MD":
if let mode = response.mode {
return "Mode: \(mode.rawValue)"
}
case "SM":
if let meter = response.sMeter {
return "S-Meter: \(meter)"
}
case "ID":
if response.isFT991A {
return "FT-991A identified"
}
default:
break
}
return response.value
}
// MARK: - Polling
func startPolling() {
guard !isPolling else { return }
isPolling = true
// Fast polling for meters
pollingTimer = Timer.scheduledTimer(withTimeInterval: fastPollInterval, repeats: true) { [weak self] _ in
self?.pollMeters()
}
// Start slow polling for frequency/mode
Timer.scheduledTimer(withTimeInterval: slowPollInterval, repeats: true) { [weak self] _ in
self?.pollStatus()
}
}
func stopPolling() {
pollingTimer?.invalidate()
pollingTimer = nil
isPolling = false
}
private func pollMeters() {
send(CAT.readSMeter)
if radioState.isTransmitting {
send(CAT.readPowerMeter)
send(CAT.readSWRMeter)
}
}
private func pollStatus() {
send(CAT.readVFOA)
send(CAT.readVFOB)
send(CAT.readActiveVFO)
send(CAT.readMode)
}
// MARK: - Initial State
private func requestInitialState() {
// Verify radio identity
send(CAT.readID)
// Request all current values
send(CAT.readVFOA)
send(CAT.readVFOB)
send(CAT.readActiveVFO)
send(CAT.readMode)
send(CAT.readAFGain)
send(CAT.readRFGain)
send(CAT.readSquelch)
send(CAT.readMICGain)
send(CAT.readPower)
send(CAT.readNB)
send(CAT.readNR)
send(CAT.readDNF)
send(CAT.readSplit)
send(CAT.readSMeter)
}
// MARK: - Radio Control
func setFrequency(_ frequency: Int, vfo: VFO = .a) {
if vfo == .a {
send(CAT.setVFOA(frequency))
radioState.vfoAFrequency = frequency
} else {
send(CAT.setVFOB(frequency))
radioState.vfoBFrequency = frequency
}
}
func changeFrequency(by step: Int) {
let newFreq = radioState.activeFrequency + step
setFrequency(newFreq, vfo: radioState.activeVFO)
}
func setMode(_ mode: OperatingMode) {
send(CAT.setMode(mode))
radioState.mode = mode
}
func setAFGain(_ value: Int) {
send(CAT.setAFGain(value))
radioState.afGain = value
}
func setRFGain(_ value: Int) {
send(CAT.setRFGain(value))
radioState.rfGain = value
}
func setSquelch(_ value: Int) {
send(CAT.setSquelch(value))
radioState.squelch = value
}
func setMICGain(_ value: Int) {
send(CAT.setMICGain(value))
radioState.micGain = value
}
func setPower(_ value: Int) {
send(CAT.setPower(value))
radioState.power = value
}
func toggleNB() {
let newValue = !radioState.noiseBlanker
send(CAT.setNB(newValue))
radioState.noiseBlanker = newValue
}
func toggleNR() {
let newValue = !radioState.noiseReduction
send(CAT.setNR(newValue))
radioState.noiseReduction = newValue
}
func toggleDNF() {
let newValue = !radioState.dnf
send(CAT.setDNF(newValue))
radioState.dnf = newValue
}
func toggleSplit() {
let newValue = !radioState.split
send(CAT.setSplit(newValue))
radioState.split = newValue
}
func selectVFO(_ vfo: VFO) {
if vfo == .a {
send(CAT.selectVFOA)
} else {
send(CAT.selectVFOB)
}
radioState.activeVFO = vfo
}
func swapVFO() {
send(CAT.swapVFO)
let temp = radioState.vfoAFrequency
radioState.vfoAFrequency = radioState.vfoBFrequency
radioState.vfoBFrequency = temp
}
func equalizeVFO() {
// Set VFO-B to VFO-A frequency
send(CAT.setVFOB(radioState.vfoAFrequency))
radioState.vfoBFrequency = radioState.vfoAFrequency
}
func startATUTune() {
send(CAT.startATUTune)
}
// MARK: - PTT Control
func startTransmit(dataMode: Bool = false) {
if dataMode {
send(CAT.txOnData)
} else {
send(CAT.txOn)
}
radioState.isTransmitting = true
}
func stopTransmit() {
send(CAT.txOff)
radioState.isTransmitting = false
}
func toggleTransmit(dataMode: Bool = false) {
if radioState.isTransmitting {
stopTransmit()
} else {
startTransmit(dataMode: dataMode)
}
}
// MARK: - Band Selection
func selectBand(_ band: Band) {
setFrequency(band.defaultFrequency, vfo: radioState.activeVFO)
}
// MARK: - Debug
func clearCommandHistory() {
commandHistory.removeAll()
}
}
// MARK: - Command Log Entry
struct CommandLogEntry: Identifiable {
let id = UUID()
let timestamp: Date
let direction: Direction
let command: String
let description: String
enum Direction {
case sent
case received
var symbol: String {
switch self {
case .sent: return ""
case .received: return ""
}
}
var color: String {
switch self {
case .sent: return "blue"
case .received: return "green"
}
}
}
var timeString: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter.string(from: timestamp)
}
}
@@ -0,0 +1,240 @@
//
// CSVManager.swift
// FT991A-Remote
//
// CSV Log file management
//
import Foundation
// MARK: - CSV Manager
class CSVManager: ObservableObject {
// MARK: - Published Properties
@Published var logEntries: [QSOEntry] = []
@Published var currentLogFile: URL?
@Published var lastError: String?
@Published var isSaving = false
// MARK: - Properties
private let fileManager = FileManager.default
private var logDirectory: URL
// MARK: - Initialization
init(logDirectory: String = "~/Documents/FT991A-Logs/") {
let expandedPath = (logDirectory as NSString).expandingTildeInPath
self.logDirectory = URL(fileURLWithPath: expandedPath, isDirectory: true)
ensureDirectoryExists()
}
// MARK: - Directory Management
private func ensureDirectoryExists() {
if !fileManager.fileExists(atPath: logDirectory.path) {
do {
try fileManager.createDirectory(at: logDirectory, withIntermediateDirectories: true)
Logger.shared.log("Created log directory: \(logDirectory.path)", level: .info)
} catch {
lastError = "Konnte Log-Verzeichnis nicht erstellen: \(error.localizedDescription)"
Logger.shared.log(lastError!, level: .error)
}
}
}
func setLogDirectory(_ path: String) {
let expandedPath = (path as NSString).expandingTildeInPath
logDirectory = URL(fileURLWithPath: expandedPath, isDirectory: true)
ensureDirectoryExists()
}
// MARK: - File Operations
func createNewLogFile(name: String? = nil) -> URL {
let fileName = name ?? generateLogFileName()
let fileURL = logDirectory.appendingPathComponent(fileName)
// Write header
do {
try QSOEntry.csvHeader.appending("\n").write(to: fileURL, atomically: true, encoding: .utf8)
currentLogFile = fileURL
Logger.shared.log("Created new log file: \(fileURL.lastPathComponent)", level: .info)
} catch {
lastError = "Konnte Log-Datei nicht erstellen: \(error.localizedDescription)"
Logger.shared.log(lastError!, level: .error)
}
return fileURL
}
private func generateLogFileName() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HHmmss"
return "QSO_Log_\(formatter.string(from: Date())).csv"
}
func openLogFile(_ url: URL) -> Bool {
guard fileManager.fileExists(atPath: url.path) else {
lastError = "Datei existiert nicht: \(url.lastPathComponent)"
return false
}
do {
let content = try String(contentsOf: url, encoding: .utf8)
logEntries = parseCSV(content)
currentLogFile = url
Logger.shared.log("Opened log file: \(url.lastPathComponent) with \(logEntries.count) entries", level: .info)
return true
} catch {
lastError = "Konnte Datei nicht lesen: \(error.localizedDescription)"
Logger.shared.log(lastError!, level: .error)
return false
}
}
// MARK: - Parsing
private func parseCSV(_ content: String) -> [QSOEntry] {
var entries: [QSOEntry] = []
let lines = content.components(separatedBy: .newlines)
for (index, line) in lines.enumerated() {
// Skip header and empty lines
guard index > 0, !line.trimmingCharacters(in: .whitespaces).isEmpty else { continue }
if let entry = QSOEntry.from(csvLine: line) {
entries.append(entry)
}
}
return entries
}
// MARK: - Entry Management
func addEntry(_ entry: QSOEntry) {
logEntries.append(entry)
saveCurrentLog()
}
func updateEntry(_ entry: QSOEntry) {
if let index = logEntries.firstIndex(where: { $0.id == entry.id }) {
logEntries[index] = entry
saveCurrentLog()
}
}
func deleteEntry(_ entry: QSOEntry) {
logEntries.removeAll { $0.id == entry.id }
saveCurrentLog()
}
func deleteEntries(at offsets: IndexSet) {
logEntries.remove(atOffsets: offsets)
saveCurrentLog()
}
// MARK: - Saving
func saveCurrentLog() {
guard let fileURL = currentLogFile else {
// Create new file if none exists
_ = createNewLogFile()
guard let newURL = currentLogFile else { return }
saveToFile(newURL)
return
}
saveToFile(fileURL)
}
private func saveToFile(_ url: URL) {
isSaving = true
var content = QSOEntry.csvHeader + "\n"
for entry in logEntries {
content += entry.csvLine + "\n"
}
do {
try content.write(to: url, atomically: true, encoding: .utf8)
Logger.shared.log("Saved \(logEntries.count) entries to \(url.lastPathComponent)", level: .debug)
} catch {
lastError = "Fehler beim Speichern: \(error.localizedDescription)"
Logger.shared.log(lastError!, level: .error)
}
isSaving = false
}
func exportToFile(_ url: URL) -> Bool {
var content = QSOEntry.csvHeader + "\n"
for entry in logEntries {
content += entry.csvLine + "\n"
}
do {
try content.write(to: url, atomically: true, encoding: .utf8)
Logger.shared.log("Exported \(logEntries.count) entries to \(url.path)", level: .info)
return true
} catch {
lastError = "Export fehlgeschlagen: \(error.localizedDescription)"
Logger.shared.log(lastError!, level: .error)
return false
}
}
// MARK: - File Listing
func listLogFiles() -> [URL] {
do {
let files = try fileManager.contentsOfDirectory(
at: logDirectory,
includingPropertiesForKeys: [.creationDateKey],
options: [.skipsHiddenFiles]
)
return files
.filter { $0.pathExtension.lowercased() == "csv" }
.sorted { url1, url2 in
let date1 = (try? url1.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date.distantPast
let date2 = (try? url2.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date.distantPast
return date1 > date2
}
} catch {
Logger.shared.log("Error listing log files: \(error)", level: .error)
return []
}
}
// MARK: - Statistics
var totalQSOs: Int {
logEntries.count
}
var uniqueCallsigns: Int {
Set(logEntries.map { $0.callsign.uppercased() }).count
}
var bandStatistics: [String: Int] {
var stats: [String: Int] = [:]
for entry in logEntries {
let band = entry.bandDisplay
stats[band, default: 0] += 1
}
return stats
}
var modeStatistics: [String: Int] {
var stats: [String: Int] = [:]
for entry in logEntries {
let mode = entry.mode.rawValue
stats[mode, default: 0] += 1
}
return stats
}
}
@@ -0,0 +1,475 @@
//
// SerialPortManager.swift
// FT991A-Remote
//
// USB Serial communication for FT-991A (Silicon Labs CP210x)
//
import Foundation
import IOKit
import IOKit.serial
// MARK: - Serial Port
struct SerialPort: Identifiable, Hashable {
let id: String
let path: String
let name: String
let vendorID: Int?
let productID: Int?
let isFT991A: Bool
init(path: String, name: String, vendorID: Int? = nil, productID: Int? = nil, isFT991A: Bool = false) {
self.id = path
self.path = path
self.name = name
self.vendorID = vendorID
self.productID = productID
self.isFT991A = isFT991A
}
}
// MARK: - Connection State
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case error(String)
var isConnected: Bool {
if case .connected = self { return true }
return false
}
var displayString: String {
switch self {
case .disconnected: return "Getrennt"
case .connecting: return "Verbinde..."
case .connected: return "Verbunden"
case .error(let msg): return "Fehler: \(msg)"
}
}
}
// MARK: - Serial Port Manager
class SerialPortManager: ObservableObject {
// MARK: - Published Properties
@Published var connectionState: ConnectionState = .disconnected
@Published var availablePorts: [SerialPort] = []
@Published var selectedPortPath: String = ""
@Published var baudRate: Int = 38400
@Published var lastError: String?
@Published var bytesSent: UInt64 = 0
@Published var bytesReceived: UInt64 = 0
// MARK: - Callbacks
var onDataReceived: ((Data) -> Void)?
var onConnectionChanged: ((Bool) -> Void)?
// MARK: - Private Properties
private var fileDescriptor: Int32 = -1
private let writeQueue = DispatchQueue(label: "ft991a.serial.write", qos: .userInteractive)
private let readQueue = DispatchQueue(label: "ft991a.serial.read", qos: .userInteractive)
private var readBuffer = Data()
private var isReading = false
private var readSource: DispatchSourceRead?
// Auto-reconnect
private var reconnectTimer: Timer?
private var shouldReconnect = false
// MARK: - Constants
private static let CP210X_VENDOR_ID = 0x10C4 // Silicon Labs
private static let CP210X_PRODUCT_ID = 0xEA60 // CP210x
// MARK: - Initialization
init() {
refreshPorts()
}
deinit {
disconnect()
}
// MARK: - Port Discovery
func refreshPorts() {
availablePorts = findSerialPorts()
// Auto-select FT-991A port (CP210x / SLAB)
if let ft991a = availablePorts.first(where: { $0.isFT991A }) {
selectedPortPath = ft991a.path
} else if selectedPortPath.isEmpty, let first = availablePorts.first {
selectedPortPath = first.path
}
}
private func findSerialPorts() -> [SerialPort] {
var ports: [SerialPort] = []
var iterator: io_iterator_t = 0
let matching = IOServiceMatching(kIOSerialBSDServiceValue)
guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else {
return ports
}
var service = IOIteratorNext(iterator)
while service != 0 {
defer {
IOObjectRelease(service)
service = IOIteratorNext(iterator)
}
guard let path = IORegistryEntryCreateCFProperty(
service, kIOCalloutDeviceKey as CFString, kCFAllocatorDefault, 0
)?.takeRetainedValue() as? String else { continue }
// Only callout devices (cu.*)
guard path.contains("cu.") else { continue }
var name = path.components(separatedBy: "/").last ?? "Unknown"
var vendorID: Int?
var productID: Int?
var isFT991A = false
// Check for Silicon Labs CP210x (FT-991A uses this)
if path.contains("SLAB_USBtoUART") || path.contains("CP210") {
isFT991A = true
name = "FT-991A (CP210x)"
}
// Walk USB registry for device info
var parent: io_object_t = 0
var current = service
IOObjectRetain(current)
for _ in 0..<10 {
if IORegistryEntryGetParentEntry(current, kIOServicePlane, &parent) != KERN_SUCCESS { break }
IOObjectRelease(current)
current = parent
if let vid = IORegistryEntryCreateCFProperty(current, "idVendor" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
vendorID = vid
}
if let pid = IORegistryEntryCreateCFProperty(current, "idProduct" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
productID = pid
}
if let usbName = IORegistryEntryCreateCFProperty(current, "USB Product Name" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? String {
if usbName.contains("CP210") || usbName.contains("UART") {
name = usbName
}
}
// Silicon Labs CP210x = likely FT-991A
if vendorID == Self.CP210X_VENDOR_ID && productID == Self.CP210X_PRODUCT_ID {
isFT991A = true
name = "FT-991A (CP210x)"
}
if vendorID != nil && productID != nil { break }
}
IOObjectRelease(current)
ports.append(SerialPort(
path: path,
name: name,
vendorID: vendorID,
productID: productID,
isFT991A: isFT991A
))
}
IOObjectRelease(iterator)
// Sort: FT-991A first, then alphabetically
return ports.sorted { ($0.isFT991A ? 0 : 1, $0.name) < ($1.isFT991A ? 0 : 1, $1.name) }
}
// MARK: - Connection
func connect() {
guard !selectedPortPath.isEmpty else {
connectionState = .error("Kein Port ausgewählt")
return
}
connectionState = .connecting
// Open port
fileDescriptor = open(selectedPortPath, O_RDWR | O_NOCTTY | O_NONBLOCK)
guard fileDescriptor != -1 else {
let error = String(cString: strerror(errno))
connectionState = .error(error)
lastError = error
return
}
// Configure serial port
if !configurePort() {
close(fileDescriptor)
fileDescriptor = -1
return
}
// Clear buffers
tcflush(fileDescriptor, TCIOFLUSH)
readBuffer.removeAll()
// Start reading
startReading()
connectionState = .connected
lastError = nil
onConnectionChanged?(true)
Logger.shared.log("Connected to \(selectedPortPath) at \(baudRate) baud", level: .info)
}
private func configurePort() -> Bool {
var options = termios()
if tcgetattr(fileDescriptor, &options) != 0 {
connectionState = .error("Fehler beim Lesen der Port-Einstellungen")
return false
}
// Set baud rate
let speed = baudRateToSpeed(baudRate)
cfsetispeed(&options, speed)
cfsetospeed(&options, speed)
// 8N1 configuration
options.c_cflag &= ~UInt(PARENB) // No parity
options.c_cflag &= ~UInt(CSTOPB) // 1 stop bit
options.c_cflag &= ~UInt(CSIZE) // Clear size bits
options.c_cflag |= UInt(CS8) // 8 data bits
// Enable receiver, ignore modem control
options.c_cflag |= UInt(CREAD | CLOCAL)
// No hardware flow control
options.c_cflag &= ~UInt(CRTSCTS)
// Raw mode (no processing)
options.c_lflag &= ~UInt(ICANON | ECHO | ECHOE | ISIG)
options.c_oflag &= ~UInt(OPOST)
options.c_iflag &= ~UInt(IXON | IXOFF | IXANY | ICRNL | INLCR | IGNBRK)
// Timeouts
options.c_cc.16 = 0 // VMIN - minimum characters
options.c_cc.17 = 10 // VTIME - timeout in 0.1s
if tcsetattr(fileDescriptor, TCSANOW, &options) != 0 {
connectionState = .error("Fehler beim Setzen der Port-Einstellungen")
return false
}
return true
}
private func baudRateToSpeed(_ rate: Int) -> speed_t {
switch rate {
case 4800: return speed_t(B4800)
case 9600: return speed_t(B9600)
case 19200: return speed_t(B19200)
case 38400: return speed_t(B38400)
case 57600: return speed_t(B57600)
case 115200: return speed_t(B115200)
default: return speed_t(B38400)
}
}
func disconnect() {
stopReading()
stopReconnectTimer()
if fileDescriptor != -1 {
close(fileDescriptor)
fileDescriptor = -1
}
connectionState = .disconnected
onConnectionChanged?(false)
Logger.shared.log("Disconnected", level: .info)
}
func toggleConnection() {
if connectionState.isConnected {
disconnect()
} else {
connect()
}
}
// MARK: - Reading
private func startReading() {
guard fileDescriptor != -1 else { return }
isReading = true
readSource = DispatchSource.makeReadSource(fileDescriptor: fileDescriptor, queue: readQueue)
readSource?.setEventHandler { [weak self] in
self?.readAvailableData()
}
readSource?.setCancelHandler { [weak self] in
self?.isReading = false
}
readSource?.resume()
}
private func stopReading() {
readSource?.cancel()
readSource = nil
isReading = false
}
private func readAvailableData() {
guard fileDescriptor != -1 else { return }
var buffer = [UInt8](repeating: 0, count: 256)
let bytesRead = read(fileDescriptor, &buffer, buffer.count)
guard bytesRead > 0 else {
if bytesRead < 0 && errno != EAGAIN {
DispatchQueue.main.async {
self.handleReadError()
}
}
return
}
let data = Data(buffer[0..<bytesRead])
DispatchQueue.main.async {
self.bytesReceived += UInt64(bytesRead)
}
// Append to buffer
readBuffer.append(data)
// Process complete responses (terminated by ';')
processBuffer()
}
private func processBuffer() {
while let semicolonIndex = readBuffer.firstIndex(of: 0x3B) { // ';'
let responseData = readBuffer.prefix(through: semicolonIndex)
readBuffer.removeFirst(semicolonIndex + 1)
if let response = String(data: Data(responseData), encoding: .ascii) {
Logger.shared.log("RX: \(response)", level: .debug)
}
onDataReceived?(Data(responseData))
}
}
private func handleReadError() {
let error = String(cString: strerror(errno))
connectionState = .error(error)
lastError = error
if shouldReconnect {
startReconnectTimer()
}
}
// MARK: - Writing
func send(_ data: Data) {
guard fileDescriptor != -1 else { return }
writeQueue.async { [weak self] in
guard let self = self, self.fileDescriptor != -1 else { return }
let written = data.withUnsafeBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return -1 }
return write(self.fileDescriptor, base, data.count)
}
if written > 0 {
DispatchQueue.main.async {
self.bytesSent += UInt64(written)
}
if let command = String(data: data, encoding: .ascii) {
Logger.shared.log("TX: \(command.trimmingCharacters(in: .whitespaces))", level: .debug)
}
} else if written < 0 {
DispatchQueue.main.async {
self.handleWriteError()
}
}
}
}
func send(_ command: CATCommand) {
send(command.data)
}
func sendString(_ string: String) {
if let data = string.data(using: .ascii) {
send(data)
}
}
private func handleWriteError() {
let error = String(cString: strerror(errno))
connectionState = .error(error)
lastError = error
}
// MARK: - Auto-Reconnect
func enableAutoReconnect(_ enabled: Bool) {
shouldReconnect = enabled
if !enabled {
stopReconnectTimer()
}
}
private func startReconnectTimer() {
stopReconnectTimer()
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
Logger.shared.log("Attempting to reconnect...", level: .info)
self.refreshPorts()
if self.availablePorts.contains(where: { $0.path == self.selectedPortPath }) {
self.connect()
if self.connectionState.isConnected {
self.stopReconnectTimer()
}
}
}
}
private func stopReconnectTimer() {
reconnectTimer?.invalidate()
reconnectTimer = nil
}
// MARK: - Statistics
func resetStatistics() {
bytesSent = 0
bytesReceived = 0
}
var isConnected: Bool {
connectionState.isConnected
}
}
@@ -0,0 +1,126 @@
/* German Localization for FT991A-Remote */
/* Connection */
"Connected" = "Verbunden";
"Disconnected" = "Getrennt";
"Connecting" = "Verbinde...";
"Connect" = "Verbinden";
"Disconnect" = "Trennen";
"Select Port" = "Port wählen...";
"Refresh Ports" = "Ports aktualisieren";
"No Port Selected" = "Kein Port ausgewählt";
"Connection Error" = "Verbindungsfehler";
"Auto Reconnect" = "Auto-Reconnect";
/* Frequency */
"Frequency" = "Frequenz";
"Step" = "Schritt";
"VFO-A" = "VFO-A";
"VFO-B" = "VFO-B";
"Swap VFO" = "VFO tauschen";
"Band" = "Band";
/* Modes */
"Mode" = "Betriebsart";
"Filter Width" = "Filterbreite";
"Filter Shift" = "Filter-Shift";
/* Levels */
"Levels" = "Pegel";
"AF Gain" = "AF Verstärkung";
"RF Gain" = "RF Verstärkung";
"Squelch" = "Squelch";
"MIC Gain" = "MIC Verstärkung";
"Power" = "Leistung";
/* Functions */
"Functions" = "Funktionen";
"Noise Blanker" = "Störaustaster";
"Noise Reduction" = "Rauschminderung";
"ATU Tune" = "Tuner abstimmen";
"Split" = "Split-Betrieb";
/* Metering */
"Metering" = "Messwerte";
"S-Meter" = "S-Meter";
"Power Meter" = "Leistungsmesser";
"SWR Meter" = "SWR-Meter";
/* PTT */
"PTT" = "PTT";
"Transmit" = "Senden";
"Receive" = "Empfangen";
"TX" = "TX";
"RX" = "RX";
"Hold Shift for PTT" = "Shift-Taste gedrückt halten = PTT";
/* Log */
"QSO Log" = "QSO Log";
"Add QSO" = "QSO hinzufügen";
"Edit QSO" = "QSO bearbeiten";
"Delete QSO" = "QSO löschen";
"Callsign" = "Rufzeichen";
"Date" = "Datum";
"Time" = "Zeit";
"RST Sent" = "RST gesendet";
"RST Received" = "RST empfangen";
"Name" = "Name";
"QTH" = "QTH";
"Locator" = "Locator";
"Notes" = "Notizen";
"Search" = "Suchen...";
"Export" = "Exportieren";
"Import" = "Importieren";
/* Debug */
"CAT Console" = "CAT Konsole";
"Send Command" = "Befehl senden";
"Clear History" = "Verlauf löschen";
"Auto Scroll" = "Auto-Scroll";
"Bytes Sent" = "Bytes gesendet";
"Bytes Received" = "Bytes empfangen";
"Commands" = "Befehle";
/* Settings */
"Settings" = "Einstellungen";
"Connection" = "Verbindung";
"Interface" = "Oberfläche";
"Audio" = "Audio";
"Keyboard" = "Tastatur";
"Logging" = "Logging";
"UI Style" = "UI-Stil";
"Modern" = "Modern";
"Front Panel" = "Frontpanel";
"Language" = "Sprache";
"German" = "Deutsch";
"English" = "English";
"Frequency Step" = "Frequenzschritt";
"Log Directory" = "Log-Verzeichnis";
"Auto Save" = "Automatisch speichern";
"Reset to Defaults" = "Auf Standard zurücksetzen";
/* Audio */
"Audio Routing" = "Audio-Routing";
"Input Device" = "Eingabegerät";
"Output Device" = "Ausgabegerät";
"BlackHole Status" = "BlackHole Status";
"Installed" = "Installiert";
"Not Found" = "Nicht gefunden";
"Configure for Digital Modes" = "Für Digimodes konfigurieren";
"Use BlackHole" = "BlackHole verwenden";
/* Menu */
"Radio" = "Radio";
"View" = "Ansicht";
"Show Debug Panel" = "Debug-Panel anzeigen";
"Show Log Panel" = "Log-Panel anzeigen";
"Open Main Window" = "Hauptfenster öffnen";
"Quit" = "Beenden";
/* Errors */
"Error" = "Fehler";
"Failed to connect" = "Verbindung fehlgeschlagen";
"Failed to open port" = "Port konnte nicht geöffnet werden";
"No serial ports found" = "Keine seriellen Ports gefunden";
"Save failed" = "Speichern fehlgeschlagen";
"Export failed" = "Export fehlgeschlagen";
@@ -0,0 +1,126 @@
/* English Localization for FT991A-Remote */
/* Connection */
"Connected" = "Connected";
"Disconnected" = "Disconnected";
"Connecting" = "Connecting...";
"Connect" = "Connect";
"Disconnect" = "Disconnect";
"Select Port" = "Select Port...";
"Refresh Ports" = "Refresh Ports";
"No Port Selected" = "No Port Selected";
"Connection Error" = "Connection Error";
"Auto Reconnect" = "Auto Reconnect";
/* Frequency */
"Frequency" = "Frequency";
"Step" = "Step";
"VFO-A" = "VFO-A";
"VFO-B" = "VFO-B";
"Swap VFO" = "Swap VFO";
"Band" = "Band";
/* Modes */
"Mode" = "Mode";
"Filter Width" = "Filter Width";
"Filter Shift" = "Filter Shift";
/* Levels */
"Levels" = "Levels";
"AF Gain" = "AF Gain";
"RF Gain" = "RF Gain";
"Squelch" = "Squelch";
"MIC Gain" = "MIC Gain";
"Power" = "Power";
/* Functions */
"Functions" = "Functions";
"Noise Blanker" = "Noise Blanker";
"Noise Reduction" = "Noise Reduction";
"ATU Tune" = "ATU Tune";
"Split" = "Split";
/* Metering */
"Metering" = "Metering";
"S-Meter" = "S-Meter";
"Power Meter" = "Power Meter";
"SWR Meter" = "SWR Meter";
/* PTT */
"PTT" = "PTT";
"Transmit" = "Transmit";
"Receive" = "Receive";
"TX" = "TX";
"RX" = "RX";
"Hold Shift for PTT" = "Hold Shift key for PTT";
/* Log */
"QSO Log" = "QSO Log";
"Add QSO" = "Add QSO";
"Edit QSO" = "Edit QSO";
"Delete QSO" = "Delete QSO";
"Callsign" = "Callsign";
"Date" = "Date";
"Time" = "Time";
"RST Sent" = "RST Sent";
"RST Received" = "RST Received";
"Name" = "Name";
"QTH" = "QTH";
"Locator" = "Locator";
"Notes" = "Notes";
"Search" = "Search...";
"Export" = "Export";
"Import" = "Import";
/* Debug */
"CAT Console" = "CAT Console";
"Send Command" = "Send Command";
"Clear History" = "Clear History";
"Auto Scroll" = "Auto Scroll";
"Bytes Sent" = "Bytes Sent";
"Bytes Received" = "Bytes Received";
"Commands" = "Commands";
/* Settings */
"Settings" = "Settings";
"Connection" = "Connection";
"Interface" = "Interface";
"Audio" = "Audio";
"Keyboard" = "Keyboard";
"Logging" = "Logging";
"UI Style" = "UI Style";
"Modern" = "Modern";
"Front Panel" = "Front Panel";
"Language" = "Language";
"German" = "German";
"English" = "English";
"Frequency Step" = "Frequency Step";
"Log Directory" = "Log Directory";
"Auto Save" = "Auto Save";
"Reset to Defaults" = "Reset to Defaults";
/* Audio */
"Audio Routing" = "Audio Routing";
"Input Device" = "Input Device";
"Output Device" = "Output Device";
"BlackHole Status" = "BlackHole Status";
"Installed" = "Installed";
"Not Found" = "Not Found";
"Configure for Digital Modes" = "Configure for Digital Modes";
"Use BlackHole" = "Use BlackHole";
/* Menu */
"Radio" = "Radio";
"View" = "View";
"Show Debug Panel" = "Show Debug Panel";
"Show Log Panel" = "Show Log Panel";
"Open Main Window" = "Open Main Window";
"Quit" = "Quit";
/* Errors */
"Error" = "Error";
"Failed to connect" = "Failed to connect";
"Failed to open port" = "Failed to open port";
"No serial ports found" = "No serial ports found";
"Save failed" = "Save failed";
"Export failed" = "Export failed";
@@ -0,0 +1,223 @@
//
// Logger.swift
// FT991A-Remote
//
// Debug logging system
//
import Foundation
import os.log
// MARK: - Log Level
enum LogLevel: String, Comparable {
case debug = "DEBUG"
case info = "INFO"
case warning = "WARN"
case error = "ERROR"
var osLogType: OSLogType {
switch self {
case .debug: return .debug
case .info: return .info
case .warning: return .default
case .error: return .error
}
}
var symbol: String {
switch self {
case .debug: return "🔍"
case .info: return ""
case .warning: return "⚠️"
case .error: return ""
}
}
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
let order: [LogLevel] = [.debug, .info, .warning, .error]
guard let lhsIndex = order.firstIndex(of: lhs),
let rhsIndex = order.firstIndex(of: rhs) else { return false }
return lhsIndex < rhsIndex
}
}
// MARK: - Log Entry
struct LogEntry: Identifiable {
let id = UUID()
let timestamp: Date
let level: LogLevel
let message: String
let file: String
let function: String
let line: Int
var timeString: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter.string(from: timestamp)
}
var shortFile: String {
URL(fileURLWithPath: file).lastPathComponent
}
var formattedMessage: String {
"[\(timeString)] [\(level.rawValue)] \(message)"
}
var detailedMessage: String {
"[\(timeString)] [\(level.rawValue)] [\(shortFile):\(line)] \(message)"
}
}
// MARK: - Logger
class Logger: ObservableObject {
// MARK: - Singleton
static let shared = Logger()
// MARK: - Published Properties
@Published var entries: [LogEntry] = []
@Published var minimumLevel: LogLevel = .debug
@Published var isLoggingEnabled = true
// MARK: - Private Properties
private let osLog = OSLog(subsystem: "com.ft991a.remote", category: "General")
private let queue = DispatchQueue(label: "logger.queue", qos: .utility)
private let maxEntries = 1000
// File logging
private var logFileURL: URL?
private var logFileHandle: FileHandle?
// MARK: - Initialization
private init() {
setupFileLogging()
}
deinit {
logFileHandle?.closeFile()
}
// MARK: - Logging
func log(
_ message: String,
level: LogLevel = .info,
file: String = #file,
function: String = #function,
line: Int = #line
) {
guard isLoggingEnabled, level >= minimumLevel else { return }
let entry = LogEntry(
timestamp: Date(),
level: level,
message: message,
file: file,
function: function,
line: line
)
// Console output
queue.async {
os_log("%{public}@", log: self.osLog, type: level.osLogType, entry.formattedMessage)
#if DEBUG
print(entry.detailedMessage)
#endif
}
// In-memory storage
DispatchQueue.main.async {
self.entries.append(entry)
if self.entries.count > self.maxEntries {
self.entries.removeFirst(100)
}
}
// File logging
writeToFile(entry)
}
// MARK: - Convenience Methods
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
log(message, level: .debug, file: file, function: function, line: line)
}
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
log(message, level: .info, file: file, function: function, line: line)
}
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
log(message, level: .warning, file: file, function: function, line: line)
}
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
log(message, level: .error, file: file, function: function, line: line)
}
// MARK: - File Logging
private func setupFileLogging() {
let fileManager = FileManager.default
guard let logsDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let appLogsDir = logsDir.appendingPathComponent("FT991A-Remote/Logs", isDirectory: true)
do {
try fileManager.createDirectory(at: appLogsDir, withIntermediateDirectories: true)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let fileName = "ft991a_\(formatter.string(from: Date())).log"
logFileURL = appLogsDir.appendingPathComponent(fileName)
if !fileManager.fileExists(atPath: logFileURL!.path) {
fileManager.createFile(atPath: logFileURL!.path, contents: nil)
}
logFileHandle = try FileHandle(forWritingTo: logFileURL!)
logFileHandle?.seekToEndOfFile()
let header = "\n=== FT-991A Remote Log Started at \(Date()) ===\n"
if let data = header.data(using: .utf8) {
logFileHandle?.write(data)
}
} catch {
print("Failed to setup file logging: \(error)")
}
}
private func writeToFile(_ entry: LogEntry) {
guard let handle = logFileHandle else { return }
queue.async {
if let data = (entry.detailedMessage + "\n").data(using: .utf8) {
handle.write(data)
}
}
}
// MARK: - Management
func clear() {
entries.removeAll()
}
func exportLogs() -> String {
entries.map { $0.detailedMessage }.joined(separator: "\n")
}
var filteredEntries: [LogEntry] {
entries.filter { $0.level >= minimumLevel }
}
}
@@ -0,0 +1,211 @@
//
// LogViewModel.swift
// FT991A-Remote
//
// ViewModel for QSO logging
//
import Foundation
import Combine
import SwiftUI
// MARK: - Log ViewModel
@MainActor
class LogViewModel: ObservableObject {
// MARK: - Published Properties
@Published var entries: [QSOEntry] = []
@Published var selectedEntry: QSOEntry?
@Published var searchText = ""
@Published var sortOrder: SortOrder = .dateDescending
// Current QSO being logged
@Published var currentQSO = QSOEntry()
// File management
@Published var currentLogFile: URL?
@Published var availableLogFiles: [URL] = []
@Published var isSaving = false
@Published var lastError: String?
// MARK: - Private Properties
private let csvManager = CSVManager()
private var cancellables = Set<AnyCancellable>()
// MARK: - Computed Properties
var filteredEntries: [QSOEntry] {
var result = entries
// Apply search filter
if !searchText.isEmpty {
let search = searchText.lowercased()
result = result.filter {
$0.callsign.lowercased().contains(search) ||
$0.name.lowercased().contains(search) ||
$0.qth.lowercased().contains(search) ||
$0.notes.lowercased().contains(search)
}
}
// Apply sorting
switch sortOrder {
case .dateDescending:
result.sort { $0.date > $1.date }
case .dateAscending:
result.sort { $0.date < $1.date }
case .callsignAscending:
result.sort { $0.callsign < $1.callsign }
case .callsignDescending:
result.sort { $0.callsign > $1.callsign }
case .frequencyAscending:
result.sort { $0.frequency < $1.frequency }
case .frequencyDescending:
result.sort { $0.frequency > $1.frequency }
}
return result
}
var totalQSOs: Int {
entries.count
}
var uniqueCallsigns: Int {
Set(entries.map { $0.callsign.uppercased() }).count
}
// MARK: - Initialization
init() {
setupBindings()
refreshLogFiles()
loadLatestLog()
}
private func setupBindings() {
csvManager.$logEntries
.receive(on: DispatchQueue.main)
.assign(to: &$entries)
csvManager.$currentLogFile
.receive(on: DispatchQueue.main)
.assign(to: &$currentLogFile)
csvManager.$isSaving
.receive(on: DispatchQueue.main)
.assign(to: &$isSaving)
csvManager.$lastError
.receive(on: DispatchQueue.main)
.assign(to: &$lastError)
}
// MARK: - File Management
func refreshLogFiles() {
availableLogFiles = csvManager.listLogFiles()
}
func loadLatestLog() {
if let latest = availableLogFiles.first {
_ = csvManager.openLogFile(latest)
}
}
func openLogFile(_ url: URL) {
_ = csvManager.openLogFile(url)
}
func createNewLogFile(name: String? = nil) {
_ = csvManager.createNewLogFile(name: name)
refreshLogFiles()
}
func exportToFile(_ url: URL) -> Bool {
csvManager.exportToFile(url)
}
func setLogDirectory(_ path: String) {
csvManager.setLogDirectory(path)
refreshLogFiles()
}
// MARK: - QSO Management
func addQSO() {
guard !currentQSO.callsign.isEmpty else { return }
var entry = currentQSO
entry.callsign = entry.callsign.uppercased()
csvManager.addEntry(entry)
resetCurrentQSO()
Logger.shared.log("Added QSO: \(entry.callsign)", level: .info)
}
func updateQSO(_ entry: QSOEntry) {
csvManager.updateEntry(entry)
}
func deleteQSO(_ entry: QSOEntry) {
csvManager.deleteEntry(entry)
}
func deleteQSOs(at offsets: IndexSet) {
// Convert offsets from filtered to original indices
let entriesToDelete = offsets.map { filteredEntries[$0] }
for entry in entriesToDelete {
csvManager.deleteEntry(entry)
}
}
func resetCurrentQSO() {
currentQSO = QSOEntry()
}
// MARK: - Radio Integration
func updateFromRadio(frequency: Int, mode: OperatingMode, power: Int) {
currentQSO.frequency = frequency
currentQSO.mode = mode
currentQSO.power = power
}
// MARK: - Statistics
var bandStatistics: [(band: String, count: Int)] {
var stats: [String: Int] = [:]
for entry in entries {
let band = entry.bandDisplay
stats[band, default: 0] += 1
}
return stats.map { (band: $0.key, count: $0.value) }
.sorted { $0.count > $1.count }
}
var modeStatistics: [(mode: String, count: Int)] {
var stats: [String: Int] = [:]
for entry in entries {
let mode = entry.mode.rawValue
stats[mode, default: 0] += 1
}
return stats.map { (mode: $0.key, count: $0.value) }
.sorted { $0.count > $1.count }
}
// MARK: - Sort Order
enum SortOrder: String, CaseIterable {
case dateDescending = "Datum (neu → alt)"
case dateAscending = "Datum (alt → neu)"
case callsignAscending = "Rufzeichen (A → Z)"
case callsignDescending = "Rufzeichen (Z → A)"
case frequencyAscending = "Frequenz (niedrig → hoch)"
case frequencyDescending = "Frequenz (hoch → niedrig)"
}
}
@@ -0,0 +1,306 @@
//
// RadioViewModel.swift
// FT991A-Remote
//
// Main ViewModel for radio control
//
import Foundation
import Combine
import SwiftUI
// MARK: - Radio ViewModel
@MainActor
class RadioViewModel: ObservableObject {
// MARK: - Published Properties
// Connection
@Published var isConnected = false
@Published var connectionState: ConnectionState = .disconnected
@Published var availablePorts: [SerialPort] = []
@Published var selectedPort: String = ""
@Published var baudRate: Int = 38400
// Radio State (mirrored for convenience)
@Published var vfoAFrequency: Int = 14_250_000
@Published var vfoBFrequency: Int = 14_255_000
@Published var activeVFO: VFO = .a
@Published var mode: OperatingMode = .usb
@Published var frequencyStep: FrequencyStep = .khz1
// Levels
@Published var afGain: Int = 128
@Published var rfGain: Int = 255
@Published var squelch: Int = 0
@Published var micGain: Int = 50
@Published var power: Int = 100
// Functions
@Published var noiseBlanker = false
@Published var noiseReduction = false
@Published var dnf = false
@Published var contour = false
@Published var atu = false
@Published var split = false
@Published var ipo = false
// Metering
@Published var sMeter: Int = 0
@Published var powerMeter: Int = 0
@Published var swrMeter: Int = 0
@Published var isTransmitting = false
// Statistics
@Published var bytesSent: UInt64 = 0
@Published var bytesReceived: UInt64 = 0
// Debug
@Published var commandHistory: [CommandLogEntry] = []
// MARK: - Services
private let serialManager = SerialPortManager()
private let catProtocol: CATProtocol
private var cancellables = Set<AnyCancellable>()
// MARK: - Computed Properties
var activeFrequency: Int {
activeVFO == .a ? vfoAFrequency : vfoBFrequency
}
var frequencyDisplay: String {
formatFrequency(activeFrequency)
}
var sMeterDisplay: String {
let normalized = Double(sMeter) / 255.0
if normalized <= 0.6 {
let sUnit = Int(normalized / 0.6 * 9.0)
return "S\(sUnit)"
} else {
let db = Int((normalized - 0.6) / 0.4 * 60.0)
return "S9+\(db)"
}
}
var currentBand: Band? {
Band.from(frequency: activeFrequency)
}
// MARK: - Initialization
init() {
catProtocol = CATProtocol(serialManager: serialManager)
setupBindings()
refreshPorts()
}
private func setupBindings() {
// Serial Manager bindings
serialManager.$connectionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
self?.connectionState = state
self?.isConnected = state.isConnected
}
.store(in: &cancellables)
serialManager.$availablePorts
.receive(on: DispatchQueue.main)
.assign(to: &$availablePorts)
serialManager.$selectedPortPath
.receive(on: DispatchQueue.main)
.assign(to: &$selectedPort)
serialManager.$bytesSent
.receive(on: DispatchQueue.main)
.assign(to: &$bytesSent)
serialManager.$bytesReceived
.receive(on: DispatchQueue.main)
.assign(to: &$bytesReceived)
// CAT Protocol bindings
catProtocol.$radioState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
self?.updateFromRadioState(state)
}
.store(in: &cancellables)
catProtocol.$commandHistory
.receive(on: DispatchQueue.main)
.assign(to: &$commandHistory)
}
private func updateFromRadioState(_ state: RadioState) {
vfoAFrequency = state.vfoAFrequency
vfoBFrequency = state.vfoBFrequency
activeVFO = state.activeVFO
mode = state.mode
afGain = state.afGain
rfGain = state.rfGain
squelch = state.squelch
micGain = state.micGain
power = state.power
noiseBlanker = state.noiseBlanker
noiseReduction = state.noiseReduction
dnf = state.dnf
contour = state.contour
atu = state.atu
split = state.split
ipo = state.ipo
sMeter = state.sMeter
powerMeter = state.powerMeter
swrMeter = state.swrMeter
isTransmitting = state.isTransmitting
}
// MARK: - Connection
func refreshPorts() {
serialManager.refreshPorts()
}
func connect() {
serialManager.selectedPortPath = selectedPort
serialManager.baudRate = baudRate
serialManager.connect()
}
func disconnect() {
serialManager.disconnect()
}
func toggleConnection() {
if isConnected {
disconnect()
} else {
connect()
}
}
func selectPort(_ path: String) {
selectedPort = path
serialManager.selectedPortPath = path
}
// MARK: - Frequency Control
func setFrequency(_ frequency: Int) {
catProtocol.setFrequency(frequency, vfo: activeVFO)
}
func incrementFrequency() {
catProtocol.changeFrequency(by: frequencyStep.rawValue)
}
func decrementFrequency() {
catProtocol.changeFrequency(by: -frequencyStep.rawValue)
}
func selectBand(_ band: Band) {
catProtocol.selectBand(band)
}
// MARK: - VFO Control
func selectVFO(_ vfo: VFO) {
catProtocol.selectVFO(vfo)
}
func swapVFO() {
catProtocol.swapVFO()
}
func equalizeVFO() {
catProtocol.equalizeVFO()
}
// MARK: - Mode Control
func setMode(_ mode: OperatingMode) {
catProtocol.setMode(mode)
}
// MARK: - Level Control
func setAFGain(_ value: Int) {
catProtocol.setAFGain(value)
}
func setRFGain(_ value: Int) {
catProtocol.setRFGain(value)
}
func setSquelch(_ value: Int) {
catProtocol.setSquelch(value)
}
func setMICGain(_ value: Int) {
catProtocol.setMICGain(value)
}
func setPower(_ value: Int) {
catProtocol.setPower(value)
}
// MARK: - Function Control
func toggleNB() {
catProtocol.toggleNB()
}
func toggleNR() {
catProtocol.toggleNR()
}
func toggleDNF() {
catProtocol.toggleDNF()
}
func toggleSplit() {
catProtocol.toggleSplit()
}
func startATUTune() {
catProtocol.startATUTune()
}
// MARK: - PTT Control
func startTransmit(dataMode: Bool = false) {
catProtocol.startTransmit(dataMode: dataMode)
}
func stopTransmit() {
catProtocol.stopTransmit()
}
func toggleTransmit(dataMode: Bool = false) {
catProtocol.toggleTransmit(dataMode: dataMode)
}
// MARK: - Debug
func sendRawCommand(_ command: String) {
catProtocol.sendRaw(command)
}
func clearCommandHistory() {
catProtocol.clearCommandHistory()
}
// MARK: - Helpers
func formatFrequency(_ freq: Int) -> String {
let mhz = freq / 1_000_000
let khz = (freq % 1_000_000) / 1_000
let hz = freq % 1_000
return String(format: "%d.%03d.%03d", mhz, khz, hz)
}
}
@@ -0,0 +1,172 @@
//
// SettingsController.swift
// FT991A-Remote
//
// Application settings controller
//
import Foundation
import Combine
import SwiftUI
// MARK: - Settings Controller
@MainActor
class SettingsController: ObservableObject {
// MARK: - Published Properties
// UI Settings
@Published var uiStyle: UIStyle = .modern {
didSet { saveSettings() }
}
@Published var language: AppLanguage = .german {
didSet { saveSettings() }
}
@Published var compactMode: Bool = true {
didSet { saveSettings() }
}
@Published var showDebugPanel: Bool = false {
didSet { saveSettings() }
}
@Published var showLogPanel: Bool = false {
didSet { saveSettings() }
}
// Connection Settings
@Published var autoReconnect: Bool = true {
didSet { saveSettings() }
}
@Published var reconnectInterval: TimeInterval = 5.0 {
didSet { saveSettings() }
}
@Published var defaultBaudRate: Int = 38400 {
didSet { saveSettings() }
}
// Frequency Settings
@Published var frequencyStep: FrequencyStep = .khz1 {
didSet { saveSettings() }
}
// Logging Settings
@Published var logDirectory: String = "~/Documents/FT991A-Logs/" {
didSet { saveSettings() }
}
@Published var autoSaveLog: Bool = true {
didSet { saveSettings() }
}
// Audio Settings
@Published var audioInputDevice: String = "" {
didSet { saveSettings() }
}
@Published var audioOutputDevice: String = "" {
didSet { saveSettings() }
}
@Published var useBlackHole: Bool = false {
didSet { saveSettings() }
}
// Keyboard Settings
@Published var pttShortcutEnabled: Bool = true {
didSet { saveSettings() }
}
@Published var arrowFrequencyEnabled: Bool = true {
didSet { saveSettings() }
}
@Published var tunerShortcutEnabled: Bool = true {
didSet { saveSettings() }
}
// MARK: - Private Properties
private var settings: AppSettings
private var saveDebounce: Timer?
// MARK: - Initialization
init() {
settings = AppSettings.load()
loadFromSettings()
}
// MARK: - Settings Management
private func loadFromSettings() {
uiStyle = settings.uiStyle
language = settings.language
compactMode = settings.compactMode
showDebugPanel = settings.showDebugPanel
showLogPanel = settings.showLogPanel
autoReconnect = settings.autoReconnect
reconnectInterval = settings.reconnectInterval
frequencyStep = settings.frequencyStep
logDirectory = settings.logDirectory
autoSaveLog = settings.autoSaveLog
audioInputDevice = settings.audioInputDevice
audioOutputDevice = settings.audioOutputDevice
useBlackHole = settings.useBlackHole
pttShortcutEnabled = settings.pttShortcutEnabled
arrowFrequencyEnabled = settings.arrowFrequencyEnabled
tunerShortcutEnabled = settings.tunerShortcutEnabled
defaultBaudRate = settings.baudRate
}
private func saveSettings() {
// Debounce saves to avoid excessive disk writes
saveDebounce?.invalidate()
saveDebounce = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
self?.performSave()
}
}
private func performSave() {
settings.uiStyle = uiStyle
settings.language = language
settings.compactMode = compactMode
settings.showDebugPanel = showDebugPanel
settings.showLogPanel = showLogPanel
settings.autoReconnect = autoReconnect
settings.reconnectInterval = reconnectInterval
settings.frequencyStep = frequencyStep
settings.logDirectory = logDirectory
settings.autoSaveLog = autoSaveLog
settings.audioInputDevice = audioInputDevice
settings.audioOutputDevice = audioOutputDevice
settings.useBlackHole = useBlackHole
settings.pttShortcutEnabled = pttShortcutEnabled
settings.arrowFrequencyEnabled = arrowFrequencyEnabled
settings.tunerShortcutEnabled = tunerShortcutEnabled
settings.baudRate = defaultBaudRate
settings.save()
Logger.shared.log("Settings saved", level: .debug)
}
func resetToDefaults() {
settings = AppSettings.defaults
loadFromSettings()
settings.save()
Logger.shared.log("Settings reset to defaults", level: .info)
}
// MARK: - Helpers
var expandedLogDirectory: String {
(logDirectory as NSString).expandingTildeInPath
}
static let availableBaudRates = [4800, 9600, 19200, 38400, 57600, 115200]
}
@@ -0,0 +1,216 @@
//
// MainView.swift
// FT991A-Remote
//
// Main application window container
//
import SwiftUI
// MARK: - Main View
struct MainView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
@EnvironmentObject var logViewModel: LogViewModel
@State private var isDebugPanelDetached = false
@State private var isLogPanelDetached = false
var body: some View {
NavigationSplitView {
// Sidebar
SidebarView()
.frame(minWidth: 200)
} detail: {
// Main content
HSplitView {
// Radio control area
VStack(spacing: 0) {
// Connection bar
ConnectionBar()
.padding(.horizontal)
.padding(.top, 8)
Divider()
.padding(.top, 8)
// Radio view based on UI style
if settingsController.uiStyle == .modern {
ModernRadioView()
} else {
SkeuomorphRadioView()
}
}
.frame(minWidth: 600)
// Side panels
if settingsController.showLogPanel && !isLogPanelDetached {
Divider()
LogPanel()
.frame(minWidth: 300, maxWidth: 400)
}
if settingsController.showDebugPanel && !isDebugPanelDetached {
Divider()
DebugPanel()
.frame(minWidth: 300, maxWidth: 400)
}
}
}
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
// UI Style toggle
Picker("UI", selection: $settingsController.uiStyle) {
Image(systemName: "rectangle.3.group")
.tag(UIStyle.modern)
Image(systemName: "dial.medium")
.tag(UIStyle.skeuomorph)
}
.pickerStyle(.segmented)
.help("UI-Stil wechseln")
Divider()
// Panel toggles
Toggle(isOn: $settingsController.showLogPanel) {
Image(systemName: "list.bullet.rectangle")
}
.help("Log-Panel anzeigen")
Toggle(isOn: $settingsController.showDebugPanel) {
Image(systemName: "terminal")
}
.help("Debug-Panel anzeigen")
}
}
.navigationTitle("FT-991A Remote")
.onAppear {
setupKeyboardShortcuts()
}
}
private func setupKeyboardShortcuts() {
// Keyboard shortcuts are handled in the App commands
}
}
// MARK: - Sidebar View
struct SidebarView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
List {
Section("Verbindung") {
Label {
VStack(alignment: .leading) {
Text(radioViewModel.isConnected ? "Verbunden" : "Getrennt")
.font(.headline)
if radioViewModel.isConnected {
Text(radioViewModel.selectedPort)
.font(.caption)
.foregroundColor(.secondary)
}
}
} icon: {
Image(systemName: radioViewModel.isConnected ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash")
.foregroundColor(radioViewModel.isConnected ? .green : .red)
}
}
Section("Frequenz") {
Label {
Text(radioViewModel.frequencyDisplay + " Hz")
.font(.system(.body, design: .monospaced))
} icon: {
Image(systemName: "waveform")
}
if let band = radioViewModel.currentBand {
Label(band.rawValue, systemImage: "chart.bar")
}
Label(radioViewModel.mode.rawValue, systemImage: "waveform.path")
}
Section("Bänder") {
ForEach(Band.allCases, id: \.self) { band in
Button {
radioViewModel.selectBand(band)
} label: {
Label(band.rawValue, systemImage: "antenna.radiowaves.left.and.right")
}
.disabled(!radioViewModel.isConnected)
}
}
}
.listStyle(.sidebar)
}
}
// MARK: - Connection Bar
struct ConnectionBar: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
HStack(spacing: 12) {
// Port selection
Picker("Port", selection: $radioViewModel.selectedPort) {
Text("Port wählen...").tag("")
ForEach(radioViewModel.availablePorts) { port in
Text(port.name).tag(port.path)
}
}
.frame(width: 200)
// Refresh button
Button {
radioViewModel.refreshPorts()
} label: {
Image(systemName: "arrow.clockwise")
}
.help("Ports aktualisieren")
// Baud rate
Picker("Baud", selection: $radioViewModel.baudRate) {
ForEach(SerialConfig.availableBaudRates, id: \.self) { rate in
Text("\(rate)").tag(rate)
}
}
.frame(width: 100)
Spacer()
// Connection status
HStack(spacing: 6) {
Circle()
.fill(radioViewModel.isConnected ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(radioViewModel.connectionState.displayString)
.foregroundColor(.secondary)
}
// Connect button
Button {
radioViewModel.toggleConnection()
} label: {
Text(radioViewModel.isConnected ? "Trennen" : "Verbinden")
}
.keyboardShortcut("k", modifiers: .command)
}
.padding(.vertical, 8)
}
}
// MARK: - Preview
#Preview {
MainView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
.environmentObject(LogViewModel())
.frame(width: 1200, height: 800)
}
@@ -0,0 +1,134 @@
//
// MenuBarView.swift
// FT991A-Remote
//
// Menu bar extra for background operation
//
import SwiftUI
// MARK: - Menu Bar View
struct MenuBarView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Connection status
HStack {
Circle()
.fill(radioViewModel.isConnected ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(radioViewModel.isConnected ? "Verbunden" : "Getrennt")
.font(.headline)
Spacer()
Button(radioViewModel.isConnected ? "Trennen" : "Verbinden") {
radioViewModel.toggleConnection()
}
.controlSize(.small)
}
if radioViewModel.isConnected {
Divider()
// Frequency display
VStack(alignment: .leading, spacing: 4) {
Text("Frequenz")
.font(.caption)
.foregroundColor(.secondary)
Text(radioViewModel.frequencyDisplay + " Hz")
.font(.system(.title3, design: .monospaced))
}
// Mode and Band
HStack {
Text(radioViewModel.mode.rawValue)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.2))
.cornerRadius(4)
if let band = radioViewModel.currentBand {
Text(band.rawValue)
.foregroundColor(.secondary)
}
Spacer()
Text(radioViewModel.sMeterDisplay)
.font(.caption.monospacedDigit())
}
// TX Status
if radioViewModel.isTransmitting {
HStack {
Circle()
.fill(Color.red)
.frame(width: 10, height: 10)
Text("Senden")
.foregroundColor(.red)
}
}
Divider()
// Quick controls
HStack(spacing: 12) {
Button {
radioViewModel.selectVFO(radioViewModel.activeVFO == .a ? .b : .a)
} label: {
Text("VFO \(radioViewModel.activeVFO.rawValue)")
}
.controlSize(.small)
Button("A/B") {
radioViewModel.swapVFO()
}
.controlSize(.small)
Button("ATU") {
radioViewModel.startATUTune()
}
.controlSize(.small)
}
}
Divider()
// App controls
Button("Hauptfenster öffnen") {
NSApp.activate(ignoringOtherApps: true)
if let window = NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
Button("Einstellungen...") {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
.keyboardShortcut(",", modifiers: .command)
Divider()
Button("Beenden") {
NSApp.terminate(nil)
}
.keyboardShortcut("q", modifiers: .command)
}
.padding()
.frame(width: 280)
}
}
// MARK: - Preview
#Preview {
MenuBarView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
}
@@ -0,0 +1,576 @@
//
// ModernRadioView.swift
// FT991A-Remote
//
// Modern UI style for radio control
//
import SwiftUI
// MARK: - Modern Radio View
struct ModernRadioView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Frequency Section
FrequencyView()
// Mode & Filter Section
HStack(spacing: 20) {
ModeView()
Spacer()
LevelView()
}
// Functions Section
FunctionsView()
// Metering Section
MeteringView()
// PTT Section
PTTButton()
Spacer()
}
.padding()
}
}
}
// MARK: - Frequency View
struct FrequencyView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
@State private var frequencyInput = ""
@State private var isEditing = false
var body: some View {
GroupBox("Frequenz") {
VStack(spacing: 16) {
// VFO Selection
HStack {
// VFO A
Button {
radioViewModel.selectVFO(.a)
} label: {
HStack {
Circle()
.fill(radioViewModel.activeVFO == .a ? Color.green : Color.gray.opacity(0.3))
.frame(width: 12, height: 12)
Text("VFO-A")
.font(.headline)
Text(radioViewModel.formatFrequency(radioViewModel.vfoAFrequency))
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(radioViewModel.activeVFO == .a ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
Spacer()
// VFO controls
HStack(spacing: 8) {
Button("A/B") {
radioViewModel.swapVFO()
}
.help("VFO A und B tauschen")
Button("A=B") {
radioViewModel.equalizeVFO()
}
.help("VFO B auf A-Frequenz setzen")
}
.disabled(!radioViewModel.isConnected)
Spacer()
// VFO B
Button {
radioViewModel.selectVFO(.b)
} label: {
HStack {
Text(radioViewModel.formatFrequency(radioViewModel.vfoBFrequency))
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
Text("VFO-B")
.font(.headline)
Circle()
.fill(radioViewModel.activeVFO == .b ? Color.green : Color.gray.opacity(0.3))
.frame(width: 12, height: 12)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(radioViewModel.activeVFO == .b ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
}
// Main frequency display
HStack {
Button {
radioViewModel.decrementFrequency()
} label: {
Image(systemName: "minus.circle.fill")
.font(.title)
}
.buttonStyle(.plain)
.keyboardShortcut(.leftArrow, modifiers: [])
.disabled(!radioViewModel.isConnected)
Spacer()
// Frequency display
VStack(spacing: 4) {
Text(radioViewModel.frequencyDisplay)
.font(.system(size: 48, weight: .bold, design: .monospaced))
.foregroundColor(radioViewModel.isTransmitting ? .red : .primary)
Text("Hz")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button {
radioViewModel.incrementFrequency()
} label: {
Image(systemName: "plus.circle.fill")
.font(.title)
}
.buttonStyle(.plain)
.keyboardShortcut(.rightArrow, modifiers: [])
.disabled(!radioViewModel.isConnected)
}
// Frequency step selector
HStack {
Text("Schritt:")
.foregroundColor(.secondary)
Picker("Schritt", selection: $settingsController.frequencyStep) {
ForEach(FrequencyStep.allCases, id: \.self) { step in
Text(step.displayName).tag(step)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 500)
}
// Band buttons
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Band.allCases, id: \.self) { band in
Button {
radioViewModel.selectBand(band)
} label: {
Text(band.rawValue)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(radioViewModel.currentBand == band ? Color.accentColor : Color.secondary.opacity(0.2))
.foregroundColor(radioViewModel.currentBand == band ? .white : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
}
}
}
}
.padding()
}
}
}
// MARK: - Mode View
struct ModeView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
let commonModes: [OperatingMode] = [.lsb, .usb, .cw, .fm, .am]
let digitalModes: [OperatingMode] = [.dataLSB, .dataUSB, .rttyLSB, .rttyUSB, .c4fm]
var body: some View {
GroupBox("Betriebsart") {
VStack(alignment: .leading, spacing: 12) {
// Common modes
HStack(spacing: 8) {
ForEach(commonModes, id: \.self) { mode in
Button {
radioViewModel.setMode(mode)
} label: {
Text(mode.rawValue)
.font(.caption.bold())
.frame(width: 50)
.padding(.vertical, 6)
.background(radioViewModel.mode == mode ? Color.accentColor : Color.secondary.opacity(0.2))
.foregroundColor(radioViewModel.mode == mode ? .white : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
}
}
// Digital modes
HStack(spacing: 8) {
ForEach(digitalModes, id: \.self) { mode in
Button {
radioViewModel.setMode(mode)
} label: {
Text(mode.rawValue)
.font(.caption.bold())
.frame(minWidth: 50)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(radioViewModel.mode == mode ? Color.orange : Color.secondary.opacity(0.2))
.foregroundColor(radioViewModel.mode == mode ? .white : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
}
}
}
.padding()
}
}
}
// MARK: - Level View
struct LevelView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
GroupBox("Pegel") {
VStack(spacing: 12) {
LevelSlider(label: "AF", value: Binding(
get: { Double(radioViewModel.afGain) },
set: { radioViewModel.setAFGain(Int($0)) }
), range: 0...255, disabled: !radioViewModel.isConnected)
LevelSlider(label: "RF", value: Binding(
get: { Double(radioViewModel.rfGain) },
set: { radioViewModel.setRFGain(Int($0)) }
), range: 0...255, disabled: !radioViewModel.isConnected)
LevelSlider(label: "SQL", value: Binding(
get: { Double(radioViewModel.squelch) },
set: { radioViewModel.setSquelch(Int($0)) }
), range: 0...255, disabled: !radioViewModel.isConnected)
LevelSlider(label: "MIC", value: Binding(
get: { Double(radioViewModel.micGain) },
set: { radioViewModel.setMICGain(Int($0)) }
), range: 0...100, disabled: !radioViewModel.isConnected)
LevelSlider(label: "PWR", value: Binding(
get: { Double(radioViewModel.power) },
set: { radioViewModel.setPower(Int($0)) }
), range: 5...100, unit: "W", disabled: !radioViewModel.isConnected)
}
.padding()
}
.frame(width: 300)
}
}
// MARK: - Level Slider
struct LevelSlider: View {
let label: String
@Binding var value: Double
let range: ClosedRange<Double>
var unit: String = "%"
var disabled: Bool = false
var displayValue: String {
if unit == "W" {
return "\(Int(value))W"
} else {
let percent = (value - range.lowerBound) / (range.upperBound - range.lowerBound) * 100
return "\(Int(percent))%"
}
}
var body: some View {
HStack {
Text(label)
.font(.caption.bold())
.frame(width: 35, alignment: .leading)
Slider(value: $value, in: range)
.disabled(disabled)
Text(displayValue)
.font(.caption.monospacedDigit())
.frame(width: 45, alignment: .trailing)
}
}
}
// MARK: - Functions View
struct FunctionsView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
GroupBox("Funktionen") {
HStack(spacing: 12) {
FunctionButton(label: "NB", isActive: radioViewModel.noiseBlanker) {
radioViewModel.toggleNB()
}
.disabled(!radioViewModel.isConnected)
FunctionButton(label: "NR", isActive: radioViewModel.noiseReduction) {
radioViewModel.toggleNR()
}
.disabled(!radioViewModel.isConnected)
FunctionButton(label: "DNF", isActive: radioViewModel.dnf) {
radioViewModel.toggleDNF()
}
.disabled(!radioViewModel.isConnected)
FunctionButton(label: "CONT", isActive: radioViewModel.contour) {
// Toggle contour
}
.disabled(!radioViewModel.isConnected)
Divider()
.frame(height: 30)
FunctionButton(label: "ATU", isActive: radioViewModel.atu, color: .orange) {
radioViewModel.startATUTune()
}
.disabled(!radioViewModel.isConnected)
.keyboardShortcut(.upArrow, modifiers: [])
FunctionButton(label: "SPLIT", isActive: radioViewModel.split) {
radioViewModel.toggleSplit()
}
.disabled(!radioViewModel.isConnected)
FunctionButton(label: "IPO", isActive: radioViewModel.ipo) {
// Toggle IPO
}
.disabled(!radioViewModel.isConnected)
Spacer()
}
.padding()
}
}
}
// MARK: - Function Button
struct FunctionButton: View {
let label: String
let isActive: Bool
var color: Color = .accentColor
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.caption.bold())
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isActive ? color : Color.secondary.opacity(0.2))
.foregroundColor(isActive ? .white : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
// MARK: - Metering View
struct MeteringView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
GroupBox("Messwerte") {
VStack(spacing: 16) {
// S-Meter
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("S-Meter")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(radioViewModel.sMeterDisplay)
.font(.caption.bold().monospacedDigit())
}
SMeterBar(value: Double(radioViewModel.sMeter) / 255.0)
}
// Power meter (only shown when transmitting)
if radioViewModel.isTransmitting {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Leistung")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("\(radioViewModel.powerMeter)W")
.font(.caption.bold().monospacedDigit())
}
MeterBar(value: Double(radioViewModel.powerMeter) / 100.0, color: .orange)
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("SWR")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(String(format: "%.1f:1", 1.0 + Double(radioViewModel.swrMeter) / 50.0))
.font(.caption.bold().monospacedDigit())
}
MeterBar(value: Double(radioViewModel.swrMeter) / 255.0, color: radioViewModel.swrMeter > 100 ? .red : .green)
}
}
}
.padding()
}
}
}
// MARK: - S-Meter Bar
struct SMeterBar: View {
let value: Double
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.2))
// S-Unit markers
HStack(spacing: 0) {
ForEach(0..<10) { i in
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 1)
if i < 9 {
Spacer()
}
}
}
.padding(.horizontal, 2)
// Value bar
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [.green, .yellow, .orange, .red],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: max(0, geometry.size.width * value))
}
}
.frame(height: 20)
}
}
// MARK: - Meter Bar
struct MeterBar: View {
let value: Double
var color: Color = .green
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.2))
RoundedRectangle(cornerRadius: 4)
.fill(color)
.frame(width: max(0, geometry.size.width * min(1, value)))
}
}
.frame(height: 16)
}
}
// MARK: - PTT Button
struct PTTButton: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@State private var isPressed = false
var body: some View {
GroupBox("PTT") {
VStack(spacing: 12) {
Button {
radioViewModel.toggleTransmit()
} label: {
HStack {
Image(systemName: radioViewModel.isTransmitting ? "mic.fill" : "mic")
Text(radioViewModel.isTransmitting ? "EMPFANG" : "SENDEN")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(radioViewModel.isTransmitting ? Color.red : Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
Text("Shift-Taste gedrückt halten = PTT")
.font(.caption)
.foregroundColor(.secondary)
// TX indicator
HStack {
Circle()
.fill(radioViewModel.isTransmitting ? Color.red : Color.gray.opacity(0.3))
.frame(width: 16, height: 16)
Text(radioViewModel.isTransmitting ? "TX" : "RX")
.font(.caption.bold())
.foregroundColor(radioViewModel.isTransmitting ? .red : .green)
}
}
.padding()
}
}
}
// MARK: - Preview
#Preview {
ModernRadioView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
.frame(width: 800, height: 900)
.padding()
}
@@ -0,0 +1,148 @@
//
// AudioPanel.swift
// FT991A-Remote
//
// BlackHole audio routing panel
//
import SwiftUI
// MARK: - Audio Panel
struct AudioPanel: View {
@StateObject private var audioRouter = AudioRouter()
@EnvironmentObject var settingsController: SettingsController
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Audio Routing")
.font(.headline)
Spacer()
Button {
audioRouter.refreshDevices()
} label: {
Image(systemName: "arrow.clockwise")
}
.help("Geräte aktualisieren")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// BlackHole Status
GroupBox("BlackHole Status") {
HStack {
Circle()
.fill(audioRouter.isBlackHoleInstalled ? Color.green : Color.red)
.frame(width: 12, height: 12)
Text(audioRouter.isBlackHoleInstalled ? "Installiert" : "Nicht gefunden")
Spacer()
if !audioRouter.isBlackHoleInstalled {
Link("Installieren", destination: URL(string: "https://existential.audio/blackhole/")!)
.font(.caption)
}
}
.padding(.vertical, 4)
if let device = audioRouter.blackHoleDevice {
Text("Gerät: \(device.name)")
.font(.caption)
.foregroundColor(.secondary)
}
}
// Input Device
GroupBox("Eingang (RX Audio)") {
Picker("Eingabegerät", selection: $audioRouter.selectedInputDevice) {
Text("Keines").tag(nil as AudioDeviceID?)
ForEach(audioRouter.inputDevices) { device in
Text(device.displayName).tag(device.id as AudioDeviceID?)
}
}
.pickerStyle(.menu)
if let ft991a = audioRouter.ft991aDevice {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("FT-991A erkannt: \(ft991a.name)")
.font(.caption)
}
}
}
// Output Device
GroupBox("Ausgang (TX Audio)") {
Picker("Ausgabegerät", selection: $audioRouter.selectedOutputDevice) {
Text("Keines").tag(nil as AudioDeviceID?)
ForEach(audioRouter.outputDevices) { device in
Text(device.displayName).tag(device.id as AudioDeviceID?)
}
}
.pickerStyle(.menu)
}
// Digital Mode Configuration
GroupBox("Digitale Betriebsarten") {
VStack(alignment: .leading, spacing: 8) {
Text("Für FT8, WSPR, RTTY und andere digitale Modi:")
.font(.caption)
.foregroundColor(.secondary)
Button("Für Digimodes konfigurieren") {
_ = audioRouter.configureForDigitalModes()
}
.disabled(!audioRouter.isBlackHoleInstalled)
Toggle("BlackHole verwenden", isOn: $settingsController.useBlackHole)
.disabled(!audioRouter.isBlackHoleInstalled)
}
.padding(.vertical, 4)
}
// Routing Diagram
GroupBox("Routing-Schema") {
VStack(alignment: .leading, spacing: 4) {
Text("FT-991A USB Audio → BlackHole → WSJT-X/fldigi")
.font(.caption.monospaced())
Text("WSJT-X/fldigi → BlackHole → FT-991A USB Audio")
.font(.caption.monospaced())
}
.foregroundColor(.secondary)
.padding(.vertical, 4)
}
// Error display
if let error = audioRouter.lastError {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
Text(error)
.font(.caption)
}
}
}
.padding()
}
}
}
}
// MARK: - Preview
#Preview {
AudioPanel()
.environmentObject(SettingsController())
.frame(width: 350, height: 500)
}
@@ -0,0 +1,178 @@
//
// DebugPanel.swift
// FT991A-Remote
//
// CAT command console for debugging
//
import SwiftUI
// MARK: - Debug Panel
struct DebugPanel: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@State private var commandInput = ""
@State private var autoScroll = true
@State private var showOnlySent = false
@State private var showOnlyReceived = false
var filteredHistory: [CommandLogEntry] {
radioViewModel.commandHistory.filter { entry in
if showOnlySent && entry.direction != .sent { return false }
if showOnlyReceived && entry.direction != .received { return false }
return true
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("CAT Konsole")
.font(.headline)
Spacer()
// Filter buttons
Toggle("TX", isOn: Binding(
get: { showOnlySent },
set: { showOnlySent = $0; if $0 { showOnlyReceived = false } }
))
.toggleStyle(.button)
.controlSize(.small)
Toggle("RX", isOn: Binding(
get: { showOnlyReceived },
set: { showOnlyReceived = $0; if $0 { showOnlySent = false } }
))
.toggleStyle(.button)
.controlSize(.small)
Toggle(isOn: $autoScroll) {
Image(systemName: "arrow.down.to.line")
}
.toggleStyle(.button)
.controlSize(.small)
.help("Auto-Scroll")
Button {
radioViewModel.clearCommandHistory()
} label: {
Image(systemName: "trash")
}
.controlSize(.small)
.help("Verlauf löschen")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
Divider()
// Command history
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(filteredHistory) { entry in
CommandLogRow(entry: entry)
.id(entry.id)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
.font(.system(size: 11, design: .monospaced))
.onChange(of: radioViewModel.commandHistory.count) { _, _ in
if autoScroll, let last = filteredHistory.last {
withAnimation {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
Divider()
// Command input
HStack {
TextField("CAT-Befehl eingeben (z.B. FA;)", text: $commandInput)
.textFieldStyle(.plain)
.font(.system(size: 12, design: .monospaced))
.onSubmit {
sendCommand()
}
Button("Senden") {
sendCommand()
}
.disabled(commandInput.isEmpty || !radioViewModel.isConnected)
.keyboardShortcut(.return, modifiers: [])
}
.padding(8)
.background(Color.secondary.opacity(0.1))
// Statistics
HStack {
Text("TX: \(radioViewModel.bytesSent) Bytes")
Spacer()
Text("RX: \(radioViewModel.bytesReceived) Bytes")
Spacer()
Text("\(radioViewModel.commandHistory.count) Befehle")
}
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
private func sendCommand() {
guard !commandInput.isEmpty else { return }
var cmd = commandInput.trimmingCharacters(in: .whitespaces)
if !cmd.hasSuffix(";") {
cmd += ";"
}
radioViewModel.sendRawCommand(cmd)
commandInput = ""
}
}
// MARK: - Command Log Row
struct CommandLogRow: View {
let entry: CommandLogEntry
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text(entry.timeString)
.foregroundColor(.secondary)
.frame(width: 80, alignment: .leading)
Text(entry.direction.symbol)
.foregroundColor(entry.direction == .sent ? .blue : .green)
.frame(width: 15)
Text(entry.command)
.foregroundColor(.primary)
if !entry.description.isEmpty {
Text("// \(entry.description)")
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 1)
}
}
// MARK: - Preview
#Preview {
DebugPanel()
.environmentObject(RadioViewModel())
.frame(width: 400, height: 500)
}
@@ -0,0 +1,318 @@
//
// LogPanel.swift
// FT991A-Remote
//
// QSO Log panel
//
import SwiftUI
// MARK: - Log Panel
struct LogPanel: View {
@EnvironmentObject var logViewModel: LogViewModel
@EnvironmentObject var radioViewModel: RadioViewModel
@State private var isAddingQSO = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("QSO Log")
.font(.headline)
Spacer()
Text("\(logViewModel.totalQSOs) QSOs")
.font(.caption)
.foregroundColor(.secondary)
Button {
isAddingQSO = true
} label: {
Image(systemName: "plus")
}
.help("Neues QSO hinzufügen")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
Divider()
// Quick entry form
if isAddingQSO {
QuickLogEntry(isPresented: $isAddingQSO)
Divider()
}
// Search and filter
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Suchen...", text: $logViewModel.searchText)
.textFieldStyle(.plain)
if !logViewModel.searchText.isEmpty {
Button {
logViewModel.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
Picker("Sortierung", selection: $logViewModel.sortOrder) {
ForEach(LogViewModel.SortOrder.allCases, id: \.self) { order in
Text(order.rawValue).tag(order)
}
}
.pickerStyle(.menu)
.frame(width: 150)
}
.padding(.horizontal)
.padding(.vertical, 6)
Divider()
// QSO List
List {
ForEach(logViewModel.filteredEntries) { entry in
QSORow(entry: entry)
.contextMenu {
Button("Bearbeiten") {
logViewModel.selectedEntry = entry
}
Button("Löschen", role: .destructive) {
logViewModel.deleteQSO(entry)
}
}
}
.onDelete(perform: logViewModel.deleteQSOs)
}
.listStyle(.plain)
Divider()
// Footer with statistics
HStack {
Text("\(logViewModel.uniqueCallsigns) Stationen")
Spacer()
if let file = logViewModel.currentLogFile {
Text(file.lastPathComponent)
.lineLimit(1)
.truncationMode(.middle)
}
}
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
.padding(.vertical, 4)
}
.sheet(item: $logViewModel.selectedEntry) { entry in
QSOEditSheet(entry: entry)
}
}
}
// MARK: - QSO Row
struct QSORow: View {
let entry: QSOEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.callsign)
.font(.headline)
Spacer()
Text(entry.dateDisplay)
.font(.caption)
.foregroundColor(.secondary)
Text(entry.timeDisplay)
.font(.caption)
.foregroundColor(.secondary)
}
HStack {
Text(entry.frequencyDisplay)
.font(.caption.monospacedDigit())
Text(entry.mode.rawValue)
.font(.caption)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.2))
.cornerRadius(4)
Text(entry.bandDisplay)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("RST: \(entry.rstSent)/\(entry.rstReceived)")
.font(.caption)
.foregroundColor(.secondary)
}
if !entry.name.isEmpty || !entry.qth.isEmpty {
HStack {
if !entry.name.isEmpty {
Text(entry.name)
.font(.caption)
}
if !entry.qth.isEmpty {
Text("- \(entry.qth)")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.padding(.vertical, 4)
}
}
// MARK: - Quick Log Entry
struct QuickLogEntry: View {
@EnvironmentObject var logViewModel: LogViewModel
@EnvironmentObject var radioViewModel: RadioViewModel
@Binding var isPresented: Bool
var body: some View {
VStack(spacing: 8) {
HStack {
TextField("Rufzeichen", text: $logViewModel.currentQSO.callsign)
.textFieldStyle(.roundedBorder)
TextField("RST TX", text: $logViewModel.currentQSO.rstSent)
.textFieldStyle(.roundedBorder)
.frame(width: 50)
TextField("RST RX", text: $logViewModel.currentQSO.rstReceived)
.textFieldStyle(.roundedBorder)
.frame(width: 50)
}
HStack {
TextField("Name", text: $logViewModel.currentQSO.name)
.textFieldStyle(.roundedBorder)
TextField("QTH", text: $logViewModel.currentQSO.qth)
.textFieldStyle(.roundedBorder)
TextField("Locator", text: $logViewModel.currentQSO.locator)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
HStack {
Button("Von Radio") {
logViewModel.updateFromRadio(
frequency: radioViewModel.activeFrequency,
mode: radioViewModel.mode,
power: radioViewModel.power
)
}
.disabled(!radioViewModel.isConnected)
Spacer()
Button("Abbrechen") {
logViewModel.resetCurrentQSO()
isPresented = false
}
Button("Speichern") {
logViewModel.addQSO()
isPresented = false
}
.disabled(logViewModel.currentQSO.callsign.isEmpty)
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding()
.background(Color.secondary.opacity(0.05))
}
}
// MARK: - QSO Edit Sheet
struct QSOEditSheet: View {
@EnvironmentObject var logViewModel: LogViewModel
@Environment(\.dismiss) var dismiss
let entry: QSOEntry
@State private var editedEntry: QSOEntry
init(entry: QSOEntry) {
self.entry = entry
self._editedEntry = State(initialValue: entry)
}
var body: some View {
VStack(spacing: 16) {
Text("QSO bearbeiten")
.font(.headline)
Form {
TextField("Rufzeichen", text: $editedEntry.callsign)
TextField("Name", text: $editedEntry.name)
TextField("QTH", text: $editedEntry.qth)
TextField("Locator", text: $editedEntry.locator)
HStack {
TextField("RST TX", text: $editedEntry.rstSent)
TextField("RST RX", text: $editedEntry.rstReceived)
}
Picker("Mode", selection: $editedEntry.mode) {
ForEach(OperatingMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
}
TextField("Notizen", text: $editedEntry.notes, axis: .vertical)
.lineLimit(3...6)
}
.formStyle(.grouped)
HStack {
Button("Abbrechen") {
dismiss()
}
.keyboardShortcut(.escape, modifiers: [])
Spacer()
Button("Speichern") {
logViewModel.updateQSO(editedEntry)
dismiss()
}
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding()
.frame(width: 400, height: 400)
}
}
// MARK: - Preview
#Preview {
LogPanel()
.environmentObject(LogViewModel())
.environmentObject(RadioViewModel())
.frame(width: 350, height: 600)
}
@@ -0,0 +1,302 @@
//
// SettingsView.swift
// FT991A-Remote
//
// Application settings view
//
import SwiftUI
// MARK: - Settings View
struct SettingsView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
var body: some View {
TabView {
// Connection Settings
ConnectionSettingsView()
.tabItem {
Label("Verbindung", systemImage: "cable.connector")
}
// UI Settings
UISettingsView()
.tabItem {
Label("Oberfläche", systemImage: "paintbrush")
}
// Audio Settings
AudioSettingsView()
.tabItem {
Label("Audio", systemImage: "speaker.wave.2")
}
// Keyboard Settings
KeyboardSettingsView()
.tabItem {
Label("Tastatur", systemImage: "keyboard")
}
// Logging Settings
LoggingSettingsView()
.tabItem {
Label("Logging", systemImage: "doc.text")
}
}
.frame(width: 500, height: 400)
}
}
// MARK: - Connection Settings
struct ConnectionSettingsView: View {
@EnvironmentObject var settingsController: SettingsController
var body: some View {
Form {
Section("Serielle Verbindung") {
Picker("Standard-Baudrate", selection: $settingsController.defaultBaudRate) {
ForEach(SettingsController.availableBaudRates, id: \.self) { rate in
Text("\(rate) baud").tag(rate)
}
}
Toggle("Auto-Reconnect aktivieren", isOn: $settingsController.autoReconnect)
if settingsController.autoReconnect {
HStack {
Text("Intervall:")
Slider(value: $settingsController.reconnectInterval, in: 1...30, step: 1)
Text("\(Int(settingsController.reconnectInterval))s")
.frame(width: 30)
}
}
}
Section("FT-991A Einstellungen") {
Text("Stelle sicher, dass im Radio-Menü folgende Einstellungen aktiv sind:")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("• CAT RATE: 38400 bps")
Text("• CAT TOT: 100 ms")
Text("• CAT RTS: OFF")
}
.font(.caption.monospaced())
}
}
.formStyle(.grouped)
.padding()
}
}
// MARK: - UI Settings
struct UISettingsView: View {
@EnvironmentObject var settingsController: SettingsController
var body: some View {
Form {
Section("Erscheinungsbild") {
Picker("UI-Stil", selection: $settingsController.uiStyle) {
Text("Modern").tag(UIStyle.modern)
Text("Frontpanel (Skeuomorph)").tag(UIStyle.skeuomorph)
}
Toggle("Kompakter Modus", isOn: $settingsController.compactMode)
}
Section("Sprache") {
Picker("Sprache", selection: $settingsController.language) {
ForEach(AppLanguage.allCases, id: \.self) { lang in
Text(lang.displayName).tag(lang)
}
}
Text("Änderungen werden nach Neustart wirksam.")
.font(.caption)
.foregroundColor(.secondary)
}
Section("Frequenz") {
Picker("Standard-Schrittweite", selection: $settingsController.frequencyStep) {
ForEach(FrequencyStep.allCases, id: \.self) { step in
Text(step.displayName).tag(step)
}
}
}
}
.formStyle(.grouped)
.padding()
}
}
// MARK: - Audio Settings
struct AudioSettingsView: View {
@EnvironmentObject var settingsController: SettingsController
@StateObject private var audioRouter = AudioRouter()
var body: some View {
Form {
Section("Audio-Geräte") {
Picker("Eingabegerät", selection: $settingsController.audioInputDevice) {
Text("Standard").tag("")
ForEach(audioRouter.inputDevices) { device in
Text(device.name).tag(device.uid)
}
}
Picker("Ausgabegerät", selection: $settingsController.audioOutputDevice) {
Text("Standard").tag("")
ForEach(audioRouter.outputDevices) { device in
Text(device.name).tag(device.uid)
}
}
}
Section("BlackHole Integration") {
HStack {
Circle()
.fill(audioRouter.isBlackHoleInstalled ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(audioRouter.isBlackHoleInstalled ? "BlackHole installiert" : "BlackHole nicht gefunden")
}
Toggle("BlackHole für Digimodes verwenden", isOn: $settingsController.useBlackHole)
.disabled(!audioRouter.isBlackHoleInstalled)
if !audioRouter.isBlackHoleInstalled {
Link("BlackHole herunterladen", destination: URL(string: "https://existential.audio/blackhole/")!)
}
}
}
.formStyle(.grouped)
.padding()
.onAppear {
audioRouter.refreshDevices()
}
}
}
// MARK: - Keyboard Settings
struct KeyboardSettingsView: View {
@EnvironmentObject var settingsController: SettingsController
var body: some View {
Form {
Section("Tastaturkürzel") {
Toggle("Shift = PTT (Push-to-Talk)", isOn: $settingsController.pttShortcutEnabled)
Toggle("Pfeiltasten = Frequenz ändern", isOn: $settingsController.arrowFrequencyEnabled)
Toggle("Pfeil hoch = ATU Tune", isOn: $settingsController.tunerShortcutEnabled)
}
Section("Übersicht") {
VStack(alignment: .leading, spacing: 8) {
KeyboardShortcutRow(key: "⌘K", action: "Verbinden/Trennen")
KeyboardShortcutRow(key: "⇧⌘S", action: "VFO A/B tauschen")
KeyboardShortcutRow(key: "⇧⌘E", action: "A=B")
KeyboardShortcutRow(key: "⇧⌘T", action: "ATU Tune")
KeyboardShortcutRow(key: "⌥⌘D", action: "Debug-Panel")
KeyboardShortcutRow(key: "⌥⌘L", action: "Log-Panel")
Divider()
KeyboardShortcutRow(key: "←/→", action: "Frequenz +/-")
KeyboardShortcutRow(key: "", action: "ATU Tune")
KeyboardShortcutRow(key: "Shift", action: "PTT (halten)")
}
}
}
.formStyle(.grouped)
.padding()
}
}
// MARK: - Keyboard Shortcut Row
struct KeyboardShortcutRow: View {
let key: String
let action: String
var body: some View {
HStack {
Text(key)
.font(.system(.caption, design: .monospaced))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.2))
.cornerRadius(4)
.frame(width: 70, alignment: .leading)
Text(action)
.font(.caption)
}
}
}
// MARK: - Logging Settings
struct LoggingSettingsView: View {
@EnvironmentObject var settingsController: SettingsController
var body: some View {
Form {
Section("Log-Speicherort") {
HStack {
TextField("Verzeichnis", text: $settingsController.logDirectory)
.textFieldStyle(.roundedBorder)
Button("Wählen...") {
selectDirectory()
}
}
Text("Aktueller Pfad: \(settingsController.expandedLogDirectory)")
.font(.caption)
.foregroundColor(.secondary)
}
Section("Automatisches Speichern") {
Toggle("Log automatisch speichern", isOn: $settingsController.autoSaveLog)
Text("Speichert QSOs automatisch nach jeder Eingabe.")
.font(.caption)
.foregroundColor(.secondary)
}
Section("CSV-Format") {
Text("Felder: Call, Datum, Zeit, Frequenz, Mode, RST TX/RX, Name, QTH, Locator, Power, Notizen")
.font(.caption)
.foregroundColor(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
private func selectDirectory() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.prompt = "Auswählen"
if panel.runModal() == .OK, let url = panel.url {
settingsController.logDirectory = url.path
}
}
}
// MARK: - Preview
#Preview {
SettingsView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
}
@@ -0,0 +1,484 @@
//
// SkeuomorphRadioView.swift
// FT991A-Remote
//
// Skeuomorphic FT-991A front panel replica
//
import SwiftUI
// MARK: - Skeuomorph Radio View
struct SkeuomorphRadioView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
ZStack {
// Background - dark metal texture
LinearGradient(
colors: [Color(white: 0.15), Color(white: 0.1)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 0) {
// Top section - Display
FrontPanelDisplay()
.padding()
Divider()
.background(Color.gray.opacity(0.3))
// Middle section - Main controls
HStack(spacing: 30) {
// Left side controls
VStack(spacing: 20) {
DialKnob(label: "AF GAIN", value: Binding(
get: { Double(radioViewModel.afGain) / 255.0 },
set: { radioViewModel.setAFGain(Int($0 * 255)) }
))
DialKnob(label: "RF GAIN", value: Binding(
get: { Double(radioViewModel.rfGain) / 255.0 },
set: { radioViewModel.setRFGain(Int($0 * 255)) }
))
}
.disabled(!radioViewModel.isConnected)
Spacer()
// Center - Main VFO dial
MainVFODial()
Spacer()
// Right side controls
VStack(spacing: 20) {
DialKnob(label: "SQL", value: Binding(
get: { Double(radioViewModel.squelch) / 255.0 },
set: { radioViewModel.setSquelch(Int($0 * 255)) }
))
DialKnob(label: "MIC", value: Binding(
get: { Double(radioViewModel.micGain) / 100.0 },
set: { radioViewModel.setMICGain(Int($0 * 100)) }
))
}
.disabled(!radioViewModel.isConnected)
}
.padding(.horizontal, 40)
.padding(.vertical, 20)
Divider()
.background(Color.gray.opacity(0.3))
// Bottom section - Buttons
FrontPanelButtons()
.padding()
}
}
}
}
// MARK: - Front Panel Display
struct FrontPanelDisplay: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
ZStack {
// LCD background
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [Color(red: 0.05, green: 0.15, blue: 0.1), Color(red: 0.02, green: 0.1, blue: 0.05)],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 2)
)
VStack(spacing: 8) {
// Top row - Status indicators
HStack {
LCDIndicator(label: "VFO-A", isActive: radioViewModel.activeVFO == .a)
LCDIndicator(label: "VFO-B", isActive: radioViewModel.activeVFO == .b)
Spacer()
LCDIndicator(label: radioViewModel.mode.rawValue, isActive: true, color: .cyan)
Spacer()
LCDIndicator(label: "TX", isActive: radioViewModel.isTransmitting, color: .red)
}
.padding(.horizontal)
// Main frequency display
HStack {
Spacer()
Text(radioViewModel.frequencyDisplay)
.font(.system(size: 56, weight: .bold, design: .monospaced))
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5))
.shadow(color: Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.5), radius: 10)
Text("Hz")
.font(.system(size: 20, weight: .medium, design: .monospaced))
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.7))
Spacer()
}
// S-Meter
LCDSMeter(value: Double(radioViewModel.sMeter) / 255.0)
.padding(.horizontal)
// Bottom row - Additional info
HStack {
Text("\(radioViewModel.power)W")
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8))
Spacer()
if let band = radioViewModel.currentBand {
Text(band.rawValue)
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8))
}
Spacer()
Text(radioViewModel.sMeterDisplay)
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8))
}
.font(.system(size: 14, design: .monospaced))
.padding(.horizontal)
}
.padding()
}
.frame(height: 200)
}
}
// MARK: - LCD Indicator
struct LCDIndicator: View {
let label: String
let isActive: Bool
var color: Color = .green
var body: some View {
Text(label)
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(isActive ? color : color.opacity(0.3))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 3)
.fill(isActive ? color.opacity(0.2) : Color.clear)
)
}
}
// MARK: - LCD S-Meter
struct LCDSMeter: View {
let value: Double
var body: some View {
VStack(spacing: 2) {
// Scale labels
HStack {
ForEach([1, 3, 5, 7, 9], id: \.self) { s in
Text("S\(s)")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.5))
if s < 9 { Spacer() }
}
Text("+20")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(Color.red.opacity(0.5))
Spacer()
Text("+60")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(Color.red.opacity(0.5))
}
// Bar segments
HStack(spacing: 2) {
ForEach(0..<20, id: \.self) { i in
let threshold = Double(i) / 20.0
let isLit = value >= threshold
let isRed = i >= 12 // Above S9
RoundedRectangle(cornerRadius: 1)
.fill(isLit ? (isRed ? Color.red : Color(red: 0.3, green: 1.0, blue: 0.5)) : Color.gray.opacity(0.2))
.frame(height: 16)
}
}
}
}
}
// MARK: - Dial Knob
struct DialKnob: View {
let label: String
@Binding var value: Double
@State private var isDragging = false
@State private var lastAngle: Double = 0
var body: some View {
VStack(spacing: 8) {
Text(label)
.font(.system(size: 10, weight: .bold))
.foregroundColor(.gray)
ZStack {
// Knob base
Circle()
.fill(
LinearGradient(
colors: [Color(white: 0.3), Color(white: 0.15)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(
Circle()
.stroke(Color.gray.opacity(0.5), lineWidth: 2)
)
.shadow(color: .black.opacity(0.5), radius: 5, x: 2, y: 2)
// Knob texture (ridges)
ForEach(0..<12, id: \.self) { i in
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(width: 1, height: 25)
.offset(y: -15)
.rotationEffect(.degrees(Double(i) * 30))
}
// Indicator line
Rectangle()
.fill(Color.white)
.frame(width: 3, height: 15)
.offset(y: -20)
.rotationEffect(.degrees(value * 270 - 135))
}
.frame(width: 60, height: 60)
.gesture(
DragGesture()
.onChanged { gesture in
let center = CGPoint(x: 30, y: 30)
let location = gesture.location
let angle = atan2(location.y - center.y, location.x - center.x)
let degrees = angle * 180 / .pi + 90
if isDragging {
let delta = (degrees - lastAngle) / 270
value = min(1, max(0, value + delta))
}
lastAngle = degrees
isDragging = true
}
.onEnded { _ in
isDragging = false
}
)
Text("\(Int(value * 100))%")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.gray)
}
}
}
// MARK: - Main VFO Dial
struct MainVFODial: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
@State private var rotation: Double = 0
var body: some View {
VStack(spacing: 12) {
Text("MAIN DIAL")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.gray)
ZStack {
// Large dial
Circle()
.fill(
LinearGradient(
colors: [Color(white: 0.25), Color(white: 0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(
Circle()
.stroke(Color.gray.opacity(0.5), lineWidth: 3)
)
.shadow(color: .black.opacity(0.5), radius: 10, x: 4, y: 4)
// Dial markings
ForEach(0..<36, id: \.self) { i in
Rectangle()
.fill(Color.white.opacity(i % 3 == 0 ? 0.3 : 0.1))
.frame(width: i % 3 == 0 ? 2 : 1, height: i % 3 == 0 ? 20 : 10)
.offset(y: -65)
.rotationEffect(.degrees(Double(i) * 10 + rotation))
}
// Center cap
Circle()
.fill(Color(white: 0.2))
.frame(width: 40, height: 40)
.overlay(
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
.frame(width: 160, height: 160)
.gesture(
DragGesture()
.onChanged { gesture in
let delta = gesture.translation.width / 2
rotation += delta
// Convert rotation to frequency change
let steps = Int(delta / 10)
if steps != 0 {
for _ in 0..<abs(steps) {
if steps > 0 {
radioViewModel.incrementFrequency()
} else {
radioViewModel.decrementFrequency()
}
}
}
}
)
.disabled(!radioViewModel.isConnected)
// Step indicator
Picker("Step", selection: $settingsController.frequencyStep) {
ForEach(FrequencyStep.allCases, id: \.self) { step in
Text(step.displayName).tag(step)
}
}
.pickerStyle(.menu)
.frame(width: 100)
}
}
}
// MARK: - Front Panel Buttons
struct FrontPanelButtons: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
HStack(spacing: 12) {
// Mode buttons
Group {
PanelButton(label: "LSB", isActive: radioViewModel.mode == .lsb) {
radioViewModel.setMode(.lsb)
}
PanelButton(label: "USB", isActive: radioViewModel.mode == .usb) {
radioViewModel.setMode(.usb)
}
PanelButton(label: "CW", isActive: radioViewModel.mode == .cw) {
radioViewModel.setMode(.cw)
}
PanelButton(label: "FM", isActive: radioViewModel.mode == .fm) {
radioViewModel.setMode(.fm)
}
PanelButton(label: "AM", isActive: radioViewModel.mode == .am) {
radioViewModel.setMode(.am)
}
}
.disabled(!radioViewModel.isConnected)
Spacer()
// Function buttons
Group {
PanelButton(label: "NB", isActive: radioViewModel.noiseBlanker) {
radioViewModel.toggleNB()
}
PanelButton(label: "NR", isActive: radioViewModel.noiseReduction) {
radioViewModel.toggleNR()
}
PanelButton(label: "ATU", isActive: false, color: .orange) {
radioViewModel.startATUTune()
}
}
.disabled(!radioViewModel.isConnected)
Spacer()
// VFO buttons
Group {
PanelButton(label: "A/B", isActive: false) {
radioViewModel.swapVFO()
}
PanelButton(label: "SPLIT", isActive: radioViewModel.split) {
radioViewModel.toggleSplit()
}
}
.disabled(!radioViewModel.isConnected)
Spacer()
// PTT
PanelButton(label: radioViewModel.isTransmitting ? "RX" : "TX",
isActive: radioViewModel.isTransmitting,
color: .red,
size: .large) {
radioViewModel.toggleTransmit()
}
.disabled(!radioViewModel.isConnected)
}
}
}
// MARK: - Panel Button
struct PanelButton: View {
let label: String
let isActive: Bool
var color: Color = .green
var size: Size = .normal
let action: () -> Void
enum Size {
case normal, large
}
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: size == .large ? 14 : 11, weight: .bold))
.foregroundColor(isActive ? .white : .gray)
.frame(width: size == .large ? 60 : 45, height: size == .large ? 40 : 30)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isActive ? color : Color(white: 0.2))
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
.shadow(color: isActive ? color.opacity(0.5) : .clear, radius: 5)
)
}
.buttonStyle(.plain)
}
}
// MARK: - Preview
#Preview {
SkeuomorphRadioView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
.frame(width: 900, height: 700)
}
+144
View File
@@ -0,0 +1,144 @@
# FT-991A Remote Control App für macOS
Eine native macOS-Anwendung zur Fernsteuerung des Yaesu FT-991A Amateurfunk-Transceivers über USB (CAT-Protokoll).
## Features
### Verbindung
- USB virtueller COM-Port (Silicon Labs CP210x)
- Auto-Reconnect bei Verbindungsabbruch
- Unterstützte Baudraten: 4800, 9600, 19200, 38400 (Standard), 57600, 115200
### Benutzeroberfläche
- **Modern View**: Modernes, abstraktes UI-Design
- **Skeuomorph View**: Originalgetreue Nachbildung des FT-991A Frontpanels
- Abdockbare Panels (Log, Debug, Audio, Metering)
- Menüleisten-Betrieb für Hintergrundbetrieb
- Lokalisierung: Deutsch & Englisch
### Steuerung
- VFO A/B Frequenzsteuerung
- Betriebsarten: LSB, USB, CW, FM, AM, RTTY, DATA, C4FM
- Pegel: AF Gain, RF Gain, Squelch, MIC Gain, Power
- Funktionen: NB, NR, DNF, Contour, ATU, Split, IPO
- S-Meter, Power-Meter, SWR-Meter Anzeige
- PTT-Steuerung (Shift-Taste)
### Logging
- QSO-Log im CSV-Format
- Felder: Call, Datum, Zeit, Frequenz, Mode, RST TX/RX, Name, QTH, Locator, Power, Notizen
- Wählbarer Speicherort (Standard: ~/Documents/FT991A-Logs/)
- Automatisches Speichern
### Audio
- BlackHole Integration für digitale Betriebsarten
- Audio-Routing für WSJT-X, fldigi, etc.
### Tastaturkürzel
| Taste | Funktion |
|-------|----------|
| ⌘K | Verbinden/Trennen |
| Shift (halten) | PTT |
| ↑ | ATU Tune |
| ← / → | Frequenz -/+ |
| ⇧⌘S | VFO A/B tauschen |
| ⇧⌘E | A=B |
| ⌥⌘D | Debug-Panel |
| ⌥⌘L | Log-Panel |
## Systemanforderungen
- macOS 15.0 (Sequoia) oder neuer
- Yaesu FT-991A mit USB-Kabel
- Silicon Labs CP210x Treiber (normalerweise automatisch installiert)
## FT-991A Einstellungen
Stelle sicher, dass im Radio-Menü folgende Einstellungen aktiv sind:
```
Menu → CAT RATE: 38400 bps
Menu → CAT TOT: 100 ms
Menu → CAT RTS: OFF
```
## Installation
1. Projekt in Xcode öffnen
2. Build & Run (⌘R)
Oder für Release-Build:
1. Product → Archive
2. Distribute App → Copy App
## Projektstruktur
```
FT991A-Remote/
├── FT991A_RemoteApp.swift # App Entry Point
├── Models/
│ ├── RadioState.swift # Gerätezustand
│ ├── CATCommand.swift # CAT-Befehle
│ ├── QSOEntry.swift # Log-Einträge
│ └── Settings.swift # Einstellungen
├── Services/
│ ├── SerialPortManager.swift # USB Serial
│ ├── CATProtocol.swift # CAT Parser
│ ├── CSVManager.swift # Log-Dateien
│ └── AudioRouter.swift # BlackHole
├── ViewModels/
│ ├── RadioViewModel.swift # Radio-Logik
│ ├── LogViewModel.swift # Log-Logik
│ └── SettingsController.swift # Einstellungen
├── Views/
│ ├── MainView.swift # Hauptfenster
│ ├── ModernView/ # Moderne UI
│ ├── SkeuomorphView/ # Frontpanel
│ ├── Panels/ # Abdockbare Panels
│ ├── Settings/ # Einstellungen
│ └── MenuBar/ # Menüleiste
└── Utilities/
├── Logger.swift # Logging
└── Localization/ # DE/EN
```
## CAT-Befehle
Die App verwendet das Yaesu CAT-Protokoll. Wichtige Befehle:
| Befehl | Funktion |
|--------|----------|
| FA; | VFO-A Frequenz lesen |
| FA014250000; | VFO-A auf 14.250 MHz setzen |
| MD02; | Mode auf USB setzen |
| TX0; | PTT ein (MIC) |
| RX; | PTT aus |
| SM0; | S-Meter lesen |
## Entwicklung
### Phase 1 (aktuell)
- ✅ Projekt-Setup
- ✅ SerialPortManager
- ✅ CAT-Protokoll Parser
- ✅ RadioState Model
- ✅ Debug-UI
- ✅ Logging-System
### Phase 2-6 (geplant)
- Vollständiger CAT-Befehlssatz
- Erweiterte UI (Skeuomorph-Ansicht)
- QSO-Logging & CSV
- BlackHole Audio-Routing
- Tastaturkürzel
- Testing & Polish
## Lizenz
MIT License
## Autor
Entwickelt für Amateurfunk-Enthusiasten.
73!
+91
View File
@@ -0,0 +1,91 @@
import SwiftUI
import HealthKit
import BackgroundTasks
@main
struct HealthBridgeApp: App {
@StateObject private var appState = AppState()
@StateObject private var healthKitManager = HealthKitManager.shared
@StateObject private var syncCoordinator = SyncCoordinator.shared
init() {
registerBackgroundTasks()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
.environmentObject(healthKitManager)
.environmentObject(syncCoordinator)
.onAppear {
Task {
await requestHealthKitAuthorization()
}
}
}
}
private func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.healthbridge.sync",
using: nil
) { task in
guard let bgTask = task as? BGAppRefreshTask else { return }
handleBackgroundSync(task: bgTask)
}
}
private func handleBackgroundSync(task: BGAppRefreshTask) {
scheduleNextBackgroundSync()
let syncTask = Task {
do {
try await syncCoordinator.performSync()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
syncTask.cancel()
}
}
private func scheduleNextBackgroundSync() {
let request = BGAppRefreshTaskRequest(identifier: "com.healthbridge.sync")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule background sync: \(error)")
}
}
private func requestHealthKitAuthorization() async {
do {
try await healthKitManager.requestAuthorization()
} catch {
print("HealthKit authorization failed: \(error)")
}
}
}
// MARK: - App State
@MainActor
class AppState: ObservableObject {
@Published var selectedTab: Tab = .dashboard
@Published var showingConflictDetail: Conflict?
@Published var isLoading = false
@Published var lastSyncDate: Date?
@Published var pendingConflicts: [Conflict] = []
enum Tab {
case dashboard
case conflicts
case rules
case sources
}
}
+315
View File
@@ -0,0 +1,315 @@
import Foundation
// MARK: - Conflict
struct Conflict: Identifiable, Codable {
let id: UUID
let dataType: HealthDataType
let timeWindow: TimeWindow
var readings: [SourceReading]
var status: ConflictStatus
var resolution: ConflictResolution?
var appliedStrategy: MergeStrategy?
let detectedAt: Date
var resolvedAt: Date?
init(
id: UUID = UUID(),
dataType: HealthDataType,
timeWindow: TimeWindow,
readings: [SourceReading],
status: ConflictStatus = .pending,
resolution: ConflictResolution? = nil,
appliedStrategy: MergeStrategy? = nil,
detectedAt: Date = Date(),
resolvedAt: Date? = nil
) {
self.id = id
self.dataType = dataType
self.timeWindow = timeWindow
self.readings = readings
self.status = status
self.resolution = resolution
self.appliedStrategy = appliedStrategy
self.detectedAt = detectedAt
self.resolvedAt = resolvedAt
}
var valueDifference: Double {
guard readings.count >= 2 else { return 0 }
let values = readings.map { $0.value }
return (values.max() ?? 0) - (values.min() ?? 0)
}
var percentageDifference: Double {
guard readings.count >= 2 else { return 0 }
let values = readings.map { $0.value }
guard let min = values.min(), min > 0 else { return 0 }
guard let max = values.max() else { return 0 }
return ((max - min) / min) * 100
}
var severity: ConflictSeverity {
let pctDiff = percentageDifference
if pctDiff < 5 { return .minor }
if pctDiff < 20 { return .moderate }
if pctDiff < 50 { return .significant }
return .major
}
var highestValueReading: SourceReading? {
readings.max(by: { $0.value < $1.value })
}
var lowestValueReading: SourceReading? {
readings.min(by: { $0.value < $1.value })
}
var primarySourceReading: SourceReading? {
readings.max(by: { $0.sourceCategory.priority < $1.sourceCategory.priority })
}
}
// MARK: - Conflict Status
enum ConflictStatus: String, Codable {
case pending = "pending"
case resolved = "resolved"
case manualReview = "manual_review"
case ignored = "ignored"
var displayName: String {
switch self {
case .pending: return "Offen"
case .resolved: return "Gelöst"
case .manualReview: return "Manuelle Prüfung"
case .ignored: return "Ignoriert"
}
}
var icon: String {
switch self {
case .pending: return "clock.fill"
case .resolved: return "checkmark.circle.fill"
case .manualReview: return "hand.raised.fill"
case .ignored: return "eye.slash.fill"
}
}
}
// MARK: - Conflict Severity
enum ConflictSeverity: String, Codable {
case minor = "minor"
case moderate = "moderate"
case significant = "significant"
case major = "major"
var displayName: String {
switch self {
case .minor: return "Gering"
case .moderate: return "Moderat"
case .significant: return "Erheblich"
case .major: return "Gross"
}
}
var color: String {
switch self {
case .minor: return "green"
case .moderate: return "yellow"
case .significant: return "orange"
case .major: return "red"
}
}
}
// MARK: - Conflict Resolution
struct ConflictResolution: Codable {
let resolvedValue: Double
let secondaryResolvedValue: Double? // For blood pressure
let winningSourceId: String
let strategy: MergeStrategy
let isManual: Bool
let resolvedAt: Date
let notes: String?
init(
resolvedValue: Double,
secondaryResolvedValue: Double? = nil,
winningSourceId: String,
strategy: MergeStrategy,
isManual: Bool = false,
resolvedAt: Date = Date(),
notes: String? = nil
) {
self.resolvedValue = resolvedValue
self.secondaryResolvedValue = secondaryResolvedValue
self.winningSourceId = winningSourceId
self.strategy = strategy
self.isManual = isManual
self.resolvedAt = resolvedAt
self.notes = notes
}
}
// MARK: - Merge Strategy
enum MergeStrategy: String, Codable, CaseIterable, Identifiable {
case exclusive = "exclusive"
case priority = "priority"
case higherWins = "higher_wins"
case lowerWins = "lower_wins"
case average = "average"
case coverage = "coverage"
case coverageThenHigher = "coverage_then_higher"
case manual = "manual"
case mostRecent = "most_recent"
var id: String { rawValue }
var displayName: String {
switch self {
case .exclusive: return "Exklusiv"
case .priority: return "Priorität"
case .higherWins: return "Höherer Wert"
case .lowerWins: return "Niedrigerer Wert"
case .average: return "Durchschnitt"
case .coverage: return "Abdeckung"
case .coverageThenHigher: return "Abdeckung + Höher"
case .manual: return "Manuell"
case .mostRecent: return "Neuester"
}
}
var description: String {
switch self {
case .exclusive:
return "Nur eine Quelle kann diesen Datentyp liefern"
case .priority:
return "Höchste Priorität gewinnt basierend auf Benutzereinstellungen"
case .higherWins:
return "Der grössere Wert wird verwendet (z.B. mehr Schritte = war aktiv)"
case .lowerWins:
return "Der kleinere Wert wird verwendet"
case .average:
return "Durchschnitt aller Quellen"
case .coverage:
return "Quelle mit Daten für dieses Zeitfenster"
case .coverageThenHigher:
return "Erst Abdeckung prüfen, dann höherer Wert bei Konflikt"
case .manual:
return "Benutzer entscheidet bei jedem Konflikt"
case .mostRecent:
return "Zuletzt erfasster Wert"
}
}
var icon: String {
switch self {
case .exclusive: return "1.circle.fill"
case .priority: return "list.number"
case .higherWins: return "arrow.up.circle.fill"
case .lowerWins: return "arrow.down.circle.fill"
case .average: return "divide.circle.fill"
case .coverage: return "square.fill.on.square.fill"
case .coverageThenHigher: return "square.stack.3d.up.fill"
case .manual: return "hand.raised.fill"
case .mostRecent: return "clock.arrow.circlepath"
}
}
}
// MARK: - Merge Rule
struct MergeRule: Identifiable, Codable {
let id: UUID
let dataType: HealthDataType
var strategy: MergeStrategy
var primarySourceId: String?
var fallbackSourceId: String?
var sourcePriorities: [String: Int]
var autoApply: Bool
var thresholdForManualReview: Double? // Percentage difference threshold
init(
id: UUID = UUID(),
dataType: HealthDataType,
strategy: MergeStrategy,
primarySourceId: String? = nil,
fallbackSourceId: String? = nil,
sourcePriorities: [String: Int] = [:],
autoApply: Bool = true,
thresholdForManualReview: Double? = nil
) {
self.id = id
self.dataType = dataType
self.strategy = strategy
self.primarySourceId = primarySourceId
self.fallbackSourceId = fallbackSourceId
self.sourcePriorities = sourcePriorities
self.autoApply = autoApply
self.thresholdForManualReview = thresholdForManualReview
}
static func defaultRule(for dataType: HealthDataType) -> MergeRule {
switch dataType {
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate:
return MergeRule(dataType: dataType, strategy: .exclusive)
case .floorsClimbed:
return MergeRule(dataType: dataType, strategy: .exclusive)
case .steps, .distance, .activeEnergy:
return MergeRule(dataType: dataType, strategy: .coverageThenHigher)
case .sleep:
return MergeRule(dataType: dataType, strategy: .priority)
}
}
}
// MARK: - Sync Record
struct SyncRecord: Identifiable, Codable {
let id: UUID
let dataType: HealthDataType
let timeWindow: TimeWindow
var readings: [SourceReading]
var mergedValue: Double?
var secondaryMergedValue: Double? // For blood pressure
var strategy: MergeStrategy
var status: SyncStatus
var hasConflict: Bool
var conflictId: UUID?
let createdAt: Date
var processedAt: Date?
enum SyncStatus: String, Codable {
case pending = "pending"
case processing = "processing"
case completed = "completed"
case failed = "failed"
case requiresManualReview = "requires_manual"
}
init(
id: UUID = UUID(),
dataType: HealthDataType,
timeWindow: TimeWindow,
readings: [SourceReading],
mergedValue: Double? = nil,
secondaryMergedValue: Double? = nil,
strategy: MergeStrategy = .priority,
status: SyncStatus = .pending,
hasConflict: Bool = false,
conflictId: UUID? = nil,
createdAt: Date = Date(),
processedAt: Date? = nil
) {
self.id = id
self.dataType = dataType
self.timeWindow = timeWindow
self.readings = readings
self.mergedValue = mergedValue
self.secondaryMergedValue = secondaryMergedValue
self.strategy = strategy
self.status = status
self.hasConflict = hasConflict
self.conflictId = conflictId
self.createdAt = createdAt
self.processedAt = processedAt
}
}
+269
View File
@@ -0,0 +1,269 @@
import Foundation
import HealthKit
// MARK: - Health Data Type
enum HealthDataType: String, CaseIterable, Codable, Identifiable {
case steps = "steps"
case heartRate = "heart_rate"
case bloodPressureSystolic = "blood_pressure_systolic"
case bloodPressureDiastolic = "blood_pressure_diastolic"
case bloodOxygen = "blood_oxygen"
case sleep = "sleep"
case distance = "distance"
case floorsClimbed = "floors_climbed"
case activeEnergy = "active_energy"
case restingHeartRate = "resting_heart_rate"
case heartRateVariability = "hrv"
case respiratoryRate = "respiratory_rate"
var id: String { rawValue }
var displayName: String {
switch self {
case .steps: return "Schritte"
case .heartRate: return "Herzfrequenz"
case .bloodPressureSystolic: return "Blutdruck (Systolisch)"
case .bloodPressureDiastolic: return "Blutdruck (Diastolisch)"
case .bloodOxygen: return "Blutsauerstoff (SpO2)"
case .sleep: return "Schlaf"
case .distance: return "Distanz"
case .floorsClimbed: return "Stockwerke"
case .activeEnergy: return "Aktive Energie"
case .restingHeartRate: return "Ruhepuls"
case .heartRateVariability: return "HRV"
case .respiratoryRate: return "Atemfrequenz"
}
}
var icon: String {
switch self {
case .steps: return "figure.walk"
case .heartRate, .restingHeartRate: return "heart.fill"
case .bloodPressureSystolic, .bloodPressureDiastolic: return "drop.fill"
case .bloodOxygen: return "lungs.fill"
case .sleep: return "bed.double.fill"
case .distance: return "map.fill"
case .floorsClimbed: return "stairs"
case .activeEnergy: return "flame.fill"
case .heartRateVariability: return "waveform.path.ecg"
case .respiratoryRate: return "wind"
}
}
var unit: String {
switch self {
case .steps: return "Schritte"
case .heartRate, .restingHeartRate: return "bpm"
case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg"
case .bloodOxygen: return "%"
case .sleep: return "h"
case .distance: return "km"
case .floorsClimbed: return "Stockwerke"
case .activeEnergy: return "kcal"
case .heartRateVariability: return "ms"
case .respiratoryRate: return "/min"
}
}
var hkQuantityType: HKQuantityType? {
switch self {
case .steps:
return HKQuantityType.quantityType(forIdentifier: .stepCount)
case .heartRate:
return HKQuantityType.quantityType(forIdentifier: .heartRate)
case .bloodPressureSystolic:
return HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic)
case .bloodPressureDiastolic:
return HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic)
case .bloodOxygen:
return HKQuantityType.quantityType(forIdentifier: .oxygenSaturation)
case .distance:
return HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)
case .floorsClimbed:
return HKQuantityType.quantityType(forIdentifier: .flightsClimbed)
case .activeEnergy:
return HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
case .restingHeartRate:
return HKQuantityType.quantityType(forIdentifier: .restingHeartRate)
case .heartRateVariability:
return HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
case .respiratoryRate:
return HKQuantityType.quantityType(forIdentifier: .respiratoryRate)
case .sleep:
return nil // Sleep uses category type
}
}
var hkCategoryType: HKCategoryType? {
switch self {
case .sleep:
return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)
default:
return nil
}
}
var hkUnit: HKUnit {
switch self {
case .steps, .floorsClimbed:
return .count()
case .heartRate, .restingHeartRate, .respiratoryRate:
return HKUnit.count().unitDivided(by: .minute())
case .bloodPressureSystolic, .bloodPressureDiastolic:
return .millimeterOfMercury()
case .bloodOxygen:
return .percent()
case .sleep:
return .hour()
case .distance:
return .meterUnit(with: .kilo)
case .activeEnergy:
return .kilocalorie()
case .heartRateVariability:
return .secondUnit(with: .milli)
}
}
/// Default primary source for this data type
var defaultPrimarySource: SourceCategory {
switch self {
case .floorsClimbed:
return .iPhone
case .steps, .heartRate, .bloodPressureSystolic, .bloodPressureDiastolic,
.bloodOxygen, .sleep, .distance, .activeEnergy, .restingHeartRate,
.heartRateVariability, .respiratoryRate:
return .watch
}
}
/// Whether this data type typically has only one source
var isExclusive: Bool {
switch self {
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate, .sleep:
return true
default:
return false
}
}
}
// MARK: - Source Category
enum SourceCategory: String, Codable, CaseIterable {
case iPhone = "iphone"
case watch = "watch"
case thirdPartyWatch = "third_party_watch"
case thirdPartyApp = "third_party_app"
case healthBridge = "health_bridge"
case unknown = "unknown"
var displayName: String {
switch self {
case .iPhone: return "iPhone"
case .watch: return "Apple Watch"
case .thirdPartyWatch: return "Drittanbieter-Watch"
case .thirdPartyApp: return "Drittanbieter-App"
case .healthBridge: return "HealthBridge"
case .unknown: return "Unbekannt"
}
}
var icon: String {
switch self {
case .iPhone: return "iphone"
case .watch: return "applewatch"
case .thirdPartyWatch: return "applewatch.side.right"
case .thirdPartyApp: return "app.badge"
case .healthBridge: return "arrow.triangle.2.circlepath"
case .unknown: return "questionmark.circle"
}
}
var priority: Int {
switch self {
case .healthBridge: return 100
case .watch: return 80
case .thirdPartyWatch: return 70
case .iPhone: return 50
case .thirdPartyApp: return 30
case .unknown: return 0
}
}
}
// MARK: - Data Quality
enum DataQuality: String, Codable {
case complete = "complete"
case partial = "partial"
case missing = "missing"
case invalid = "invalid"
var icon: String {
switch self {
case .complete: return "checkmark.circle.fill"
case .partial: return "circle.lefthalf.filled"
case .missing: return "circle.dashed"
case .invalid: return "xmark.circle.fill"
}
}
var color: String {
switch self {
case .complete: return "green"
case .partial: return "yellow"
case .missing: return "gray"
case .invalid: return "red"
}
}
}
// MARK: - Time Window
struct TimeWindow: Codable, Hashable, Identifiable {
let start: Date
let end: Date
var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
var interval: DateInterval {
DateInterval(start: start, end: end)
}
var duration: TimeInterval {
end.timeIntervalSince(start)
}
var formattedRange: String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
}
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: start)
}
static func windows(for date: Date, intervalMinutes: Int = 15) -> [TimeWindow] {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
var windows: [TimeWindow] = []
var current = startOfDay
while current < endOfDay {
let windowEnd = calendar.date(byAdding: .minute, value: intervalMinutes, to: current)!
windows.append(TimeWindow(start: current, end: min(windowEnd, endOfDay)))
current = windowEnd
}
return windows
}
static func hourlyWindows(for date: Date) -> [TimeWindow] {
windows(for: date, intervalMinutes: 60)
}
}
+183
View File
@@ -0,0 +1,183 @@
import Foundation
import HealthKit
// MARK: - Health Source
struct HealthSource: Identifiable, Codable, Hashable {
let id: String
let bundleIdentifier: String
let name: String
let category: SourceCategory
var supportedDataTypes: Set<HealthDataType>
var lastActivityDate: Date?
var userPriorities: [HealthDataType: Int]
var isEnabled: Bool
init(
id: String = UUID().uuidString,
bundleIdentifier: String,
name: String,
category: SourceCategory,
supportedDataTypes: Set<HealthDataType> = [],
lastActivityDate: Date? = nil,
userPriorities: [HealthDataType: Int] = [:],
isEnabled: Bool = true
) {
self.id = id
self.bundleIdentifier = bundleIdentifier
self.name = name
self.category = category
self.supportedDataTypes = supportedDataTypes
self.lastActivityDate = lastActivityDate
self.userPriorities = userPriorities
self.isEnabled = isEnabled
}
var displayName: String {
if name.isEmpty {
return bundleIdentifier.components(separatedBy: ".").last ?? bundleIdentifier
}
return name
}
var isHealthBridge: Bool {
bundleIdentifier == HealthBridgeConstants.bundleIdentifier
}
func priority(for dataType: HealthDataType) -> Int {
userPriorities[dataType] ?? category.priority
}
static func from(hkSource: HKSource) -> HealthSource {
let category = classifySource(bundleId: hkSource.bundleIdentifier)
return HealthSource(
id: hkSource.bundleIdentifier,
bundleIdentifier: hkSource.bundleIdentifier,
name: hkSource.name,
category: category
)
}
private static func classifySource(bundleId: String) -> SourceCategory {
let lowercased = bundleId.lowercased()
if lowercased.contains("healthbridge") {
return .healthBridge
} else if lowercased.contains("apple.health") {
return .iPhone
} else if lowercased.contains("watch") || lowercased.contains("applewatch") {
return .watch
} else if lowercased.contains("huawei") || lowercased.contains("samsung") ||
lowercased.contains("fitbit") || lowercased.contains("garmin") ||
lowercased.contains("polar") || lowercased.contains("withings") {
return .thirdPartyWatch
} else {
return .thirdPartyApp
}
}
}
// MARK: - Source Reading
struct SourceReading: Identifiable, Codable {
let id: UUID
let sourceId: String
let sourceName: String
let sourceCategory: SourceCategory
let value: Double
let secondaryValue: Double? // For blood pressure (diastolic)
let timestamp: Date
let originalRecordId: String?
let quality: DataQuality
init(
id: UUID = UUID(),
sourceId: String,
sourceName: String,
sourceCategory: SourceCategory,
value: Double,
secondaryValue: Double? = nil,
timestamp: Date,
originalRecordId: String? = nil,
quality: DataQuality = .complete
) {
self.id = id
self.sourceId = sourceId
self.sourceName = sourceName
self.sourceCategory = sourceCategory
self.value = value
self.secondaryValue = secondaryValue
self.timestamp = timestamp
self.originalRecordId = originalRecordId
self.quality = quality
}
var formattedValue: String {
if value == floor(value) {
return String(format: "%.0f", value)
}
return String(format: "%.1f", value)
}
}
// MARK: - Source Health Status
struct SourceHealthStatus: Identifiable {
let id: String
let source: HealthSource
let lastSync: Date?
let recordCount: Int
let dataGaps: [TimeWindow]
let overallQuality: DataQuality
var syncStatus: SyncStatus {
guard let lastSync = lastSync else {
return .neverSynced
}
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
if hoursSinceSync < 1 {
return .recentlySynced
} else if hoursSinceSync < 24 {
return .syncedToday
} else if hoursSinceSync < 72 {
return .stale
} else {
return .veryStale
}
}
enum SyncStatus {
case recentlySynced
case syncedToday
case stale
case veryStale
case neverSynced
var icon: String {
switch self {
case .recentlySynced: return "checkmark.circle.fill"
case .syncedToday: return "checkmark.circle"
case .stale: return "exclamationmark.circle"
case .veryStale: return "exclamationmark.triangle"
case .neverSynced: return "xmark.circle"
}
}
var description: String {
switch self {
case .recentlySynced: return "Kürzlich synchronisiert"
case .syncedToday: return "Heute synchronisiert"
case .stale: return "Sync überfällig"
case .veryStale: return "Lange nicht synchronisiert"
case .neverSynced: return "Nie synchronisiert"
}
}
}
}
// MARK: - Constants
enum HealthBridgeConstants {
static let bundleIdentifier = "com.healthbridge.merged"
static let displayName = "HealthBridge"
static let defaultSyncInterval: TimeInterval = 15 * 60 // 15 minutes
static let conflictThreshold: TimeInterval = 60 // 1 minute overlap tolerance
}
+140
View File
@@ -0,0 +1,140 @@
# HealthBridge
Intelligente Health-Daten-Synchronisation für iOS Eine "Single Source of Truth" für Gesundheitsdaten.
## Übersicht
HealthBridge liest alle Quellen aus Apple Health, erkennt Konflikte zwischen verschiedenen Geräten und Apps, merged intelligent basierend auf konfigurierbaren Regeln und schreibt bereinigte Daten zurück.
## Features
### Source Discovery
- Automatische Erkennung aller verbundenen Datenquellen
- Klassifizierung nach Gerätetyp (iPhone, Apple Watch, Drittanbieter-Watch, Apps)
- Übersicht über Fähigkeiten und unterstützte Datentypen pro Quelle
### Konflikt-Erkennung
- Automatische Erkennung von Datenkonflikten zwischen Quellen
- Zeitfenster-basierte Analyse (15-Minuten-Intervalle)
- Schweregrad-Klassifizierung (minor, moderate, significant, major)
### Merge-Strategien
- **Exclusive**: Nur eine Quelle möglich (z.B. Blutdruck, SpO2)
- **Priority**: Fixe Rangfolge basierend auf Benutzereinstellungen
- **Higher Wins**: Grösserer Wert gewinnt (ideal für Schritte)
- **Coverage**: Quelle mit Daten für Zeitfenster gewinnt
- **Coverage Then Higher**: Erst Abdeckung, dann höherer Wert
- **Average**: Durchschnitt aller Quellen
- **Manual**: Benutzer entscheidet bei jedem Konflikt
### UI-Komponenten
- **Dashboard**: Tagesübersicht aller Gesundheitsdaten mit Sync-Status
- **Konflikte**: Liste offener Konflikte mit One-Tap-Auflösung
- **Regeln**: Konfiguration der Merge-Strategien pro Datentyp
- **Quellen**: Übersicht aller erkannten Datenquellen
### Background Sync
- Automatische Synchronisierung im Hintergrund
- Konfigurierbares Intervall (15 Min bis 2 Stunden)
- Push-Benachrichtigungen bei neuen Konflikten
## Unterstützte Datentypen
| Datentyp | Primärquelle | Strategie |
|----------|--------------|-----------|
| Schritte | Watch | Coverage + Higher |
| Herzfrequenz | Watch | Exclusive |
| Blutdruck | Watch D2 | Exclusive |
| SpO2 | Watch | Exclusive |
| Schlaf | Watch | Priority |
| Distanz | Watch/iPhone | Coverage + Higher |
| Stockwerke | iPhone | Exclusive |
| Aktive Energie | Watch | Coverage + Higher |
## Architektur
```
┌─────────────────────────────────────────────────────────────────┐
│ Apple Health │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ HealthBridge App │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ DataReader │→ │ MergeEngine │→ │ DataWriter │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ▲ ▲ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │SourceManager│ │ RuleEngine │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SyncCoordinator │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Projektstruktur
```
HealthBridge/
├── App/
│ └── HealthBridgeApp.swift # App-Entry, Background Tasks
├── Models/
│ ├── HealthDataTypes.swift # Datentypen, TimeWindow
│ ├── Source.swift # HealthSource, SourceReading
│ └── Conflict.swift # Conflict, MergeStrategy, MergeRule
├── Services/
│ ├── HealthKitManager.swift # HealthKit-Integration
│ ├── SourceManager.swift # Source Discovery & Management
│ ├── DataReader.swift # Daten lesen, Konflikte erkennen
│ ├── RuleEngine.swift # Merge-Regeln verwalten & anwenden
│ ├── MergeEngine.swift # Konflikte analysieren & lösen
│ ├── DataWriter.swift # Daten zurückschreiben
│ └── SyncCoordinator.swift # Orchestrierung aller Services
├── Views/
│ ├── ContentView.swift # Tab-Navigation
│ ├── DashboardView.swift # Hauptübersicht
│ ├── ConflictsView.swift # Konflikt-Liste & Detail
│ ├── RulesView.swift # Regelwerk-Editor
│ ├── SourcesView.swift # Quellen-Übersicht
│ ├── SettingsView.swift # Einstellungen
│ └── Components/
│ └── HealthChart.swift # Diagramm-Komponenten
├── ViewModels/
│ └── DashboardViewModel.swift # Dashboard-Logik
├── Utils/
│ ├── Extensions.swift # Swift-Erweiterungen
│ └── NotificationManager.swift # Push-Benachrichtigungen
└── Resources/
├── Info.plist # App-Konfiguration
└── HealthBridge.entitlements # HealthKit-Berechtigungen
```
## Voraussetzungen
- iOS 16.0+
- Xcode 15.0+
- Apple Developer Account (für HealthKit-Entitlements)
- Physisches Gerät (HealthKit nicht im Simulator verfügbar)
## Installation
1. Projekt in Xcode öffnen
2. Team für Code Signing auswählen
3. HealthKit-Capability aktivieren
4. Auf physischem Gerät ausführen
## Berechtigungen
Die App benötigt folgende Berechtigungen:
- **HealthKit Read**: Lesen aller Gesundheitsdaten
- **HealthKit Write**: Schreiben gemergter Daten
- **Background App Refresh**: Für automatische Synchronisierung
- **Notifications**: Für Konflikt-Benachrichtigungen
## Lizenz
MIT License
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
</array>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>HealthBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchScreenBackground</string>
<key>UIImageName</key>
<string>LaunchIcon</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
<string>healthkit</string>
</array>
<!-- HealthKit -->
<key>NSHealthShareUsageDescription</key>
<string>HealthBridge benötigt Zugriff auf Ihre Gesundheitsdaten, um diese zwischen verschiedenen Quellen zu synchronisieren und Konflikte zu lösen.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>HealthBridge schreibt bereinigte Gesundheitsdaten zurück in Apple Health, um eine konsistente Datenbasis zu gewährleisten.</string>
<!-- Background Modes -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.healthbridge.sync</string>
<string>com.healthbridge.cleanup</string>
</array>
</dict>
</plist>
+488
View File
@@ -0,0 +1,488 @@
import Foundation
import HealthKit
import Combine
// MARK: - Data Reader
@MainActor
class DataReader: ObservableObject {
static let shared = DataReader()
private let healthKitManager = HealthKitManager.shared
private let sourceManager = SourceManager.shared
@Published var isReading = false
@Published var lastReadDate: Date?
@Published var detectedConflicts: [Conflict] = []
@Published var readingProgress: Double = 0
private init() {}
// MARK: - Fetch Data by Type and Date Range
func fetchData(
for dataType: HealthDataType,
from startDate: Date,
to endDate: Date,
groupByWindow intervalMinutes: Int = 15
) async throws -> [TimeWindowData] {
isReading = true
defer { isReading = false }
let samples = try await healthKitManager.fetchSamples(
for: dataType,
from: startDate,
to: endDate
)
// Group samples by source
let groupedBySource = groupBySource(samples: samples, dataType: dataType)
// Create time windows
let windows = generateTimeWindows(from: startDate, to: endDate, intervalMinutes: intervalMinutes)
// Assign samples to windows
var windowDataList: [TimeWindowData] = []
for window in windows {
let windowData = createWindowData(
window: window,
dataType: dataType,
groupedBySource: groupedBySource
)
windowDataList.append(windowData)
}
lastReadDate = Date()
return windowDataList
}
private func groupBySource(samples: [HKSample], dataType: HealthDataType) -> [String: [HKSample]] {
var grouped: [String: [HKSample]] = [:]
for sample in samples {
let sourceId = sample.sourceRevision.source.bundleIdentifier
if grouped[sourceId] == nil {
grouped[sourceId] = []
}
grouped[sourceId]?.append(sample)
}
return grouped
}
private func generateTimeWindows(from start: Date, to end: Date, intervalMinutes: Int) -> [TimeWindow] {
var windows: [TimeWindow] = []
var current = start
while current < end {
let windowEnd = min(
Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: current)!,
end
)
windows.append(TimeWindow(start: current, end: windowEnd))
current = windowEnd
}
return windows
}
private func createWindowData(
window: TimeWindow,
dataType: HealthDataType,
groupedBySource: [String: [HKSample]]
) -> TimeWindowData {
var readings: [SourceReading] = []
for (sourceId, samples) in groupedBySource {
let windowSamples = samples.filter { sample in
sample.startDate < window.end && sample.endDate > window.start
}
if !windowSamples.isEmpty {
let reading = createReading(
from: windowSamples,
sourceId: sourceId,
dataType: dataType,
window: window
)
readings.append(reading)
}
}
let hasConflict = detectConflict(in: readings, dataType: dataType)
return TimeWindowData(
timeWindow: window,
dataType: dataType,
readings: readings,
hasConflict: hasConflict
)
}
private func createReading(
from samples: [HKSample],
sourceId: String,
dataType: HealthDataType,
window: TimeWindow
) -> SourceReading {
let value: Double
var secondaryValue: Double? = nil
switch dataType {
case .steps, .floorsClimbed, .activeEnergy, .distance:
// Sum up values for cumulative types
value = samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}.reduce(0, +)
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability, .bloodOxygen:
// Average for rate-based types
let values = samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
case .bloodPressureSystolic, .bloodPressureDiastolic:
// For blood pressure, we need to handle correlations
let values = samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
case .sleep:
// Sum up sleep duration
value = samples.reduce(0) { acc, sample in
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
}
}
let source = sourceManager.sources.first { $0.bundleIdentifier == sourceId }
let category = source?.category ?? sourceManager.classifySource(sourceId)
return SourceReading(
sourceId: sourceId,
sourceName: source?.name ?? sourceId,
sourceCategory: category,
value: value,
secondaryValue: secondaryValue,
timestamp: window.start,
originalRecordId: samples.first?.uuid.uuidString,
quality: samples.isEmpty ? .missing : .complete
)
}
// MARK: - Conflict Detection
private func detectConflict(in readings: [SourceReading], dataType: HealthDataType) -> Bool {
// No conflict if less than 2 readings
guard readings.count >= 2 else { return false }
// Filter out zero values (device wasn't tracking)
let nonZeroReadings = readings.filter { $0.value > 0 }
guard nonZeroReadings.count >= 2 else { return false }
// Check if values differ significantly
let values = nonZeroReadings.map { $0.value }
guard let minVal = values.min(), let maxVal = values.max() else { return false }
// Threshold varies by data type
let threshold = conflictThreshold(for: dataType)
if minVal == 0 {
return maxVal > threshold.absoluteThreshold
}
let percentDiff = (maxVal - minVal) / minVal * 100
return percentDiff > threshold.percentageThreshold
}
private func conflictThreshold(for dataType: HealthDataType) -> ConflictThreshold {
switch dataType {
case .steps:
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 100)
case .distance:
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.1) // 100m
case .heartRate:
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 10)
case .bloodPressureSystolic, .bloodPressureDiastolic:
return ConflictThreshold(percentageThreshold: 5, absoluteThreshold: 5)
case .bloodOxygen:
return ConflictThreshold(percentageThreshold: 2, absoluteThreshold: 2)
case .floorsClimbed:
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 2)
case .activeEnergy:
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 50)
case .sleep:
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.5) // 30 min
case .restingHeartRate:
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 5)
case .heartRateVariability:
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 10)
case .respiratoryRate:
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 2)
}
}
// MARK: - Detect All Conflicts
func detectConflicts(
for date: Date,
dataTypes: [HealthDataType] = HealthDataType.allCases
) async throws -> [Conflict] {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
var allConflicts: [Conflict] = []
for (index, dataType) in dataTypes.enumerated() {
readingProgress = Double(index) / Double(dataTypes.count)
do {
let windowData = try await fetchData(
for: dataType,
from: startOfDay,
to: endOfDay
)
let conflicts = windowData
.filter { $0.hasConflict }
.map { data in
Conflict(
dataType: dataType,
timeWindow: data.timeWindow,
readings: data.readings,
status: .pending
)
}
allConflicts.append(contentsOf: conflicts)
} catch {
print("Failed to detect conflicts for \(dataType): \(error)")
}
}
readingProgress = 1.0
detectedConflicts = allConflicts
return allConflicts
}
// MARK: - Data Gaps Detection
func detectGaps(
for dataType: HealthDataType,
from startDate: Date,
to endDate: Date,
expectedIntervalMinutes: Int = 15
) async throws -> [DataGap] {
let samples = try await healthKitManager.fetchSamples(
for: dataType,
from: startDate,
to: endDate
)
guard !samples.isEmpty else {
return [DataGap(
dataType: dataType,
timeWindow: TimeWindow(start: startDate, end: endDate),
expectedRecordCount: 0,
actualRecordCount: 0
)]
}
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
var gaps: [DataGap] = []
let expectedInterval = TimeInterval(expectedIntervalMinutes * 60)
// Check gap at start
if let firstSample = sortedSamples.first,
firstSample.startDate.timeIntervalSince(startDate) > expectedInterval * 2 {
gaps.append(DataGap(
dataType: dataType,
timeWindow: TimeWindow(start: startDate, end: firstSample.startDate),
expectedRecordCount: Int(firstSample.startDate.timeIntervalSince(startDate) / expectedInterval),
actualRecordCount: 0
))
}
// Check gaps between samples
for i in 0..<(sortedSamples.count - 1) {
let current = sortedSamples[i]
let next = sortedSamples[i + 1]
let gap = next.startDate.timeIntervalSince(current.endDate)
if gap > expectedInterval * 2 {
gaps.append(DataGap(
dataType: dataType,
timeWindow: TimeWindow(start: current.endDate, end: next.startDate),
expectedRecordCount: Int(gap / expectedInterval),
actualRecordCount: 0
))
}
}
// Check gap at end
if let lastSample = sortedSamples.last,
endDate.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
gaps.append(DataGap(
dataType: dataType,
timeWindow: TimeWindow(start: lastSample.endDate, end: endDate),
expectedRecordCount: Int(endDate.timeIntervalSince(lastSample.endDate) / expectedInterval),
actualRecordCount: 0
))
}
return gaps
}
// MARK: - Aggregated Data
func fetchDailySummary(for date: Date) async throws -> DailySummary {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
var summary = DailySummary(date: date)
for dataType in HealthDataType.allCases {
do {
let samples = try await healthKitManager.fetchSamples(
for: dataType,
from: startOfDay,
to: endOfDay
)
let value = aggregateValue(samples: samples, dataType: dataType)
summary.values[dataType] = value
// Check for conflicts
let windowData = try await fetchData(
for: dataType,
from: startOfDay,
to: endOfDay
)
let conflictCount = windowData.filter { $0.hasConflict }.count
summary.conflictCounts[dataType] = conflictCount
} catch {
print("Failed to fetch \(dataType) for summary: \(error)")
}
}
return summary
}
private func aggregateValue(samples: [HKSample], dataType: HealthDataType) -> Double {
switch dataType {
case .steps, .floorsClimbed, .activeEnergy, .distance:
return samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}.reduce(0, +)
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability,
.bloodOxygen, .bloodPressureSystolic, .bloodPressureDiastolic:
let values = samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}
return values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
case .sleep:
return samples.reduce(0) { acc, sample in
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
}
}
}
}
// MARK: - Supporting Types
struct TimeWindowData: Identifiable {
let id = UUID()
let timeWindow: TimeWindow
let dataType: HealthDataType
let readings: [SourceReading]
let hasConflict: Bool
var primaryReading: SourceReading? {
readings.max { $0.sourceCategory.priority < $1.sourceCategory.priority }
}
var conflictSeverity: ConflictSeverity? {
guard hasConflict, readings.count >= 2 else { return nil }
let values = readings.map { $0.value }.filter { $0 > 0 }
guard let min = values.min(), let max = values.max(), min > 0 else { return nil }
let percentDiff = (max - min) / min * 100
if percentDiff < 5 { return .minor }
if percentDiff < 20 { return .moderate }
if percentDiff < 50 { return .significant }
return .major
}
}
struct ConflictThreshold {
let percentageThreshold: Double
let absoluteThreshold: Double
}
struct DataGap: Identifiable {
let id = UUID()
let dataType: HealthDataType
let timeWindow: TimeWindow
let expectedRecordCount: Int
let actualRecordCount: Int
var severity: GapSeverity {
let duration = timeWindow.duration
if duration < 3600 { return .minor } // < 1 hour
if duration < 4 * 3600 { return .moderate } // < 4 hours
if duration < 12 * 3600 { return .significant } // < 12 hours
return .major
}
enum GapSeverity {
case minor, moderate, significant, major
}
}
struct DailySummary {
let date: Date
var values: [HealthDataType: Double] = [:]
var conflictCounts: [HealthDataType: Int] = [:]
var lastUpdated = Date()
var totalConflicts: Int {
conflictCounts.values.reduce(0, +)
}
func formattedValue(for dataType: HealthDataType) -> String {
guard let value = values[dataType] else { return "" }
switch dataType {
case .steps, .floorsClimbed:
return String(format: "%.0f", value)
case .distance:
return String(format: "%.2f km", value)
case .heartRate, .restingHeartRate, .respiratoryRate:
return String(format: "%.0f %@", value, dataType.unit)
case .bloodPressureSystolic, .bloodPressureDiastolic:
return String(format: "%.0f mmHg", value)
case .bloodOxygen:
return String(format: "%.0f%%", value * 100)
case .activeEnergy:
return String(format: "%.0f kcal", value)
case .sleep:
let hours = Int(value)
let minutes = Int((value - Double(hours)) * 60)
return "\(hours)h \(minutes)min"
case .heartRateVariability:
return String(format: "%.0f ms", value)
}
}
}
+395
View File
@@ -0,0 +1,395 @@
import Foundation
import HealthKit
import Combine
// MARK: - Data Writer
@MainActor
class DataWriter: ObservableObject {
static let shared = DataWriter()
private let healthKitManager = HealthKitManager.shared
private let healthStore = HKHealthStore()
@Published var isWriting = false
@Published var writeProgress: Double = 0
@Published var lastWriteDate: Date?
@Published var writtenRecords: [WrittenRecord] = []
@Published var failedWrites: [FailedWrite] = []
private let processedRecordsKey = "healthbridge.processed.records"
private init() {
loadProcessedRecords()
}
// MARK: - Write Single Record
func writeRecord(_ mergedRecord: MergedRecord) async throws -> WrittenRecord {
isWriting = true
defer { isWriting = false }
// Check if already written
if isAlreadyWritten(mergedRecord) {
throw DataWriterError.duplicateRecord
}
let metadata = createMetadata(from: mergedRecord)
switch mergedRecord.dataType {
case .bloodPressureSystolic, .bloodPressureDiastolic:
// Blood pressure needs special handling
guard let diastolic = mergedRecord.secondaryValue else {
throw DataWriterError.missingSecondaryValue
}
try await writeBloodPressure(
systolic: mergedRecord.value,
diastolic: diastolic,
date: mergedRecord.timeWindow.start,
metadata: metadata
)
default:
try await writeSample(
dataType: mergedRecord.dataType,
value: mergedRecord.value,
date: mergedRecord.timeWindow.start,
metadata: metadata
)
}
let writtenRecord = WrittenRecord(
id: UUID(),
mergedRecordId: mergedRecord.id,
dataType: mergedRecord.dataType,
value: mergedRecord.value,
secondaryValue: mergedRecord.secondaryValue,
writtenAt: Date(),
timeWindow: mergedRecord.timeWindow
)
writtenRecords.append(writtenRecord)
markAsProcessed(mergedRecord)
lastWriteDate = Date()
return writtenRecord
}
// MARK: - Write Batch
func writeBatch(_ mergedRecords: [MergedRecord]) async -> BatchWriteResult {
isWriting = true
defer { isWriting = false }
var successful: [WrittenRecord] = []
var failed: [FailedWrite] = []
for (index, record) in mergedRecords.enumerated() {
writeProgress = Double(index) / Double(mergedRecords.count)
do {
let writtenRecord = try await writeRecord(record)
successful.append(writtenRecord)
} catch {
let failedWrite = FailedWrite(
mergedRecord: record,
error: error,
attemptedAt: Date()
)
failed.append(failedWrite)
failedWrites.append(failedWrite)
}
}
writeProgress = 1.0
return BatchWriteResult(
successful: successful,
failed: failed,
completedAt: Date()
)
}
// MARK: - Private Write Methods
private func writeSample(
dataType: HealthDataType,
value: Double,
date: Date,
metadata: [String: Any]
) async throws {
guard let quantityType = dataType.hkQuantityType else {
throw DataWriterError.unsupportedDataType
}
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
let sample = HKQuantitySample(
type: quantityType,
quantity: quantity,
start: date,
end: date,
metadata: metadata
)
try await healthStore.save(sample)
}
private func writeBloodPressure(
systolic: Double,
diastolic: Double,
date: Date,
metadata: [String: Any]
) async throws {
// Validate blood pressure values
let validation = BloodPressureHandler.shared.validate(
systolic: systolic,
diastolic: diastolic
)
if !validation.isValid {
throw DataWriterError.invalidValue(validation.issues.joined(separator: ", "))
}
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic),
let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
throw DataWriterError.unsupportedDataType
}
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
let systolicSample = HKQuantitySample(
type: systolicType,
quantity: systolicQuantity,
start: date,
end: date,
metadata: metadata
)
let diastolicSample = HKQuantitySample(
type: diastolicType,
quantity: diastolicQuantity,
start: date,
end: date,
metadata: metadata
)
let correlation = HKCorrelation(
type: correlationType,
start: date,
end: date,
objects: [systolicSample, diastolicSample],
metadata: metadata
)
try await healthStore.save(correlation)
}
// MARK: - Metadata
private func createMetadata(from record: MergedRecord) -> [String: Any] {
var metadata: [String: Any] = [
HKMetadataKeyWasUserEntered: false,
"HealthBridgeSource": HealthBridgeConstants.bundleIdentifier,
"OriginalSourceId": record.originalSourceId,
"MergeStrategy": record.strategy.rawValue,
"MergedRecordId": record.id.uuidString,
"MergedAt": ISO8601DateFormatter().string(from: record.createdAt)
]
for (key, value) in record.metadata {
metadata["HB_\(key)"] = value
}
return metadata
}
// MARK: - Duplicate Prevention
private var processedRecordIds: Set<String> = []
private func loadProcessedRecords() {
if let data = UserDefaults.standard.data(forKey: processedRecordsKey),
let ids = try? JSONDecoder().decode(Set<String>.self, from: data) {
processedRecordIds = ids
}
}
private func saveProcessedRecords() {
if let data = try? JSONEncoder().encode(processedRecordIds) {
UserDefaults.standard.set(data, forKey: processedRecordsKey)
}
}
private func isAlreadyWritten(_ record: MergedRecord) -> Bool {
let identifier = createRecordIdentifier(record)
return processedRecordIds.contains(identifier)
}
private func markAsProcessed(_ record: MergedRecord) {
let identifier = createRecordIdentifier(record)
processedRecordIds.insert(identifier)
saveProcessedRecords()
// Cleanup old records (keep last 7 days)
cleanupOldRecords()
}
private func createRecordIdentifier(_ record: MergedRecord) -> String {
let components = [
record.dataType.rawValue,
String(record.timeWindow.start.timeIntervalSince1970),
String(record.value)
]
return components.joined(separator: "-")
}
private func cleanupOldRecords() {
// Keep only identifiers that contain recent timestamps
let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
let cutoffTimestamp = sevenDaysAgo.timeIntervalSince1970
processedRecordIds = processedRecordIds.filter { identifier in
guard let parts = identifier.split(separator: "-").dropFirst().first,
let timestamp = Double(parts) else {
return false
}
return timestamp > cutoffTimestamp
}
saveProcessedRecords()
}
// MARK: - Delete Records
func deleteHealthBridgeRecords(
for dataType: HealthDataType,
from startDate: Date,
to endDate: Date
) async throws -> Int {
guard let sampleType = dataType.hkQuantityType else {
throw DataWriterError.unsupportedDataType
}
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { [weak self] _, samplesOrNil, errorOrNil in
guard let self = self else {
continuation.resume(throwing: DataWriterError.unknownError)
return
}
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
guard let samples = samplesOrNil else {
continuation.resume(returning: 0)
return
}
// Filter to only HealthBridge records
let healthBridgeSamples = samples.filter { sample in
if let metadata = sample.metadata,
let source = metadata["HealthBridgeSource"] as? String {
return source == HealthBridgeConstants.bundleIdentifier
}
return false
}
guard !healthBridgeSamples.isEmpty else {
continuation.resume(returning: 0)
return
}
Task {
do {
try await self.healthStore.delete(healthBridgeSamples)
continuation.resume(returning: healthBridgeSamples.count)
} catch {
continuation.resume(throwing: error)
}
}
}
healthStore.execute(query)
}
}
}
// MARK: - Supporting Types
struct WrittenRecord: Identifiable, Codable {
let id: UUID
let mergedRecordId: UUID
let dataType: HealthDataType
let value: Double
let secondaryValue: Double?
let writtenAt: Date
let timeWindow: TimeWindow
}
struct FailedWrite: Identifiable {
let id = UUID()
let mergedRecord: MergedRecord
let error: Error
let attemptedAt: Date
var errorMessage: String {
error.localizedDescription
}
}
struct BatchWriteResult {
let successful: [WrittenRecord]
let failed: [FailedWrite]
let completedAt: Date
var successCount: Int { successful.count }
var failureCount: Int { failed.count }
var totalCount: Int { successCount + failureCount }
var successRate: Double {
guard totalCount > 0 else { return 1.0 }
return Double(successCount) / Double(totalCount)
}
}
// MARK: - Errors
enum DataWriterError: LocalizedError {
case unsupportedDataType
case duplicateRecord
case missingSecondaryValue
case invalidValue(String)
case writeFailed(String)
case unknownError
var errorDescription: String? {
switch self {
case .unsupportedDataType:
return "Dieser Datentyp wird nicht unterstützt"
case .duplicateRecord:
return "Dieser Datensatz wurde bereits geschrieben"
case .missingSecondaryValue:
return "Fehlender sekundärer Wert (z.B. diastolischer Blutdruck)"
case .invalidValue(let message):
return "Ungültiger Wert: \(message)"
case .writeFailed(let message):
return "Schreiben fehlgeschlagen: \(message)"
case .unknownError:
return "Unbekannter Fehler"
}
}
}
@@ -0,0 +1,391 @@
import Foundation
import HealthKit
import Combine
// MARK: - HealthKit Manager
@MainActor
class HealthKitManager: ObservableObject {
static let shared = HealthKitManager()
private let healthStore = HKHealthStore()
@Published var isAuthorized = false
@Published var authorizationStatus: [HealthDataType: HKAuthorizationStatus] = [:]
@Published var discoveredSources: [HealthSource] = []
@Published var sourceHealthStatus: [String: SourceHealthStatus] = [:]
@Published var lastError: Error?
private init() {}
// MARK: - Authorization
var allQuantityTypes: Set<HKQuantityType> {
var types = Set<HKQuantityType>()
for dataType in HealthDataType.allCases {
if let quantityType = dataType.hkQuantityType {
types.insert(quantityType)
}
}
return types
}
var allCategoryTypes: Set<HKCategoryType> {
var types = Set<HKCategoryType>()
for dataType in HealthDataType.allCases {
if let categoryType = dataType.hkCategoryType {
types.insert(categoryType)
}
}
return types
}
var allSampleTypes: Set<HKSampleType> {
var types = Set<HKSampleType>()
allQuantityTypes.forEach { types.insert($0) }
allCategoryTypes.forEach { types.insert($0) }
return types
}
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.healthDataNotAvailable
}
let typesToRead = allSampleTypes
let typesToWrite = allQuantityTypes
try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead)
isAuthorized = true
await updateAuthorizationStatus()
await discoverSources()
}
func updateAuthorizationStatus() async {
for dataType in HealthDataType.allCases {
if let quantityType = dataType.hkQuantityType {
let status = healthStore.authorizationStatus(for: quantityType)
authorizationStatus[dataType] = status
} else if let categoryType = dataType.hkCategoryType {
let status = healthStore.authorizationStatus(for: categoryType)
authorizationStatus[dataType] = status
}
}
}
// MARK: - Source Discovery
func discoverSources() async {
var allSources: [String: HealthSource] = [:]
for dataType in HealthDataType.allCases {
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
continue
}
do {
let sources = try await fetchSources(for: sampleType)
for source in sources {
if var existingSource = allSources[source.bundleIdentifier] {
existingSource.supportedDataTypes.insert(dataType)
allSources[source.bundleIdentifier] = existingSource
} else {
var newSource = source
newSource.supportedDataTypes.insert(dataType)
allSources[source.bundleIdentifier] = newSource
}
}
} catch {
print("Failed to fetch sources for \(dataType): \(error)")
}
}
discoveredSources = Array(allSources.values).sorted { $0.category.priority > $1.category.priority }
// Update source health status
for source in discoveredSources {
await updateSourceHealth(source)
}
}
private func fetchSources(for sampleType: HKSampleType) async throws -> [HealthSource] {
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
// Handled via continuation
}
return try await withCheckedThrowingContinuation { continuation in
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
guard let sources = sourcesOrNil else {
continuation.resume(returning: [])
return
}
let healthSources = sources.map { HealthSource.from(hkSource: $0) }
continuation.resume(returning: healthSources)
}
healthStore.execute(query)
}
}
private func updateSourceHealth(_ source: HealthSource) async {
var recordCount = 0
var lastActivity: Date?
for dataType in source.supportedDataTypes {
if let quantityType = dataType.hkQuantityType {
let predicate = HKQuery.predicateForObjects(from: HKSource(bundleIdentifier: source.bundleIdentifier, name: source.name) )
// Simplified: just get count
if let count = try? await fetchRecordCount(for: quantityType, source: source) {
recordCount += count
}
if let date = try? await fetchLastActivityDate(for: quantityType, source: source) {
if lastActivity == nil || date > lastActivity! {
lastActivity = date
}
}
}
}
let status = SourceHealthStatus(
id: source.id,
source: source,
lastSync: lastActivity,
recordCount: recordCount,
dataGaps: [], // TODO: Implement gap detection
overallQuality: recordCount > 0 ? .complete : .missing
)
sourceHealthStatus[source.id] = status
}
private func fetchRecordCount(for sampleType: HKSampleType, source: HealthSource) async throws -> Int {
let calendar = Calendar.current
let now = Date()
let startOfDay = calendar.startOfDay(for: now)
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samplesOrNil, errorOrNil in
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
let samples = samplesOrNil ?? []
let matchingSamples = samples.filter { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
continuation.resume(returning: matchingSamples.count)
}
self.healthStore.execute(query)
}
}
private func fetchLastActivityDate(for sampleType: HKSampleType, source: HealthSource) async throws -> Date? {
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: nil,
limit: 1,
sortDescriptors: [sortDescriptor]
) { _, samplesOrNil, errorOrNil in
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
let matchingSample = samplesOrNil?.first { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
continuation.resume(returning: matchingSample?.endDate)
}
self.healthStore.execute(query)
}
}
// MARK: - Source Classification
func classifySource(_ source: HKSource) -> SourceCategory {
let bundleId = source.bundleIdentifier.lowercased()
if bundleId.contains("healthbridge") {
return .healthBridge
} else if bundleId.contains("apple.health") && !bundleId.contains("watch") {
return .iPhone
} else if bundleId.contains("apple") && bundleId.contains("watch") {
return .watch
} else if bundleId.contains("huawei") {
return .thirdPartyWatch
} else if bundleId.contains("samsung") || bundleId.contains("galaxy") {
return .thirdPartyWatch
} else if bundleId.contains("fitbit") {
return .thirdPartyWatch
} else if bundleId.contains("garmin") {
return .thirdPartyWatch
} else if bundleId.contains("polar") {
return .thirdPartyWatch
} else if bundleId.contains("withings") {
return .thirdPartyWatch
} else {
return .thirdPartyApp
}
}
func getSourceCapabilities(_ source: HealthSource) -> Set<HealthDataType> {
return source.supportedDataTypes
}
// MARK: - Data Fetching (Basic)
func fetchSamples(
for dataType: HealthDataType,
from startDate: Date,
to endDate: Date
) async throws -> [HKSample] {
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
throw HealthKitError.unsupportedDataType
}
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]
) { _, samplesOrNil, errorOrNil in
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: samplesOrNil ?? [])
}
self.healthStore.execute(query)
}
}
// MARK: - Data Writing
func writeSample(
dataType: HealthDataType,
value: Double,
secondaryValue: Double? = nil,
date: Date,
metadata: [String: Any]? = nil
) async throws {
guard let quantityType = dataType.hkQuantityType else {
throw HealthKitError.unsupportedDataType
}
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
let sample = HKQuantitySample(
type: quantityType,
quantity: quantity,
start: date,
end: date,
metadata: metadata
)
try await healthStore.save(sample)
}
func writeBloodPressure(
systolic: Double,
diastolic: Double,
date: Date,
metadata: [String: Any]? = nil
) async throws {
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) else {
throw HealthKitError.unsupportedDataType
}
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
let systolicSample = HKQuantitySample(
type: systolicType,
quantity: systolicQuantity,
start: date,
end: date,
metadata: metadata
)
let diastolicSample = HKQuantitySample(
type: diastolicType,
quantity: diastolicQuantity,
start: date,
end: date,
metadata: metadata
)
// Create correlation for blood pressure
guard let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
throw HealthKitError.unsupportedDataType
}
let correlation = HKCorrelation(
type: correlationType,
start: date,
end: date,
objects: [systolicSample, diastolicSample],
metadata: metadata
)
try await healthStore.save(correlation)
}
}
// MARK: - HealthKit Errors
enum HealthKitError: LocalizedError {
case healthDataNotAvailable
case authorizationDenied
case unsupportedDataType
case noDataFound
case writeFailed(Error)
case queryFailed(Error)
var errorDescription: String? {
switch self {
case .healthDataNotAvailable:
return "Health-Daten sind auf diesem Gerät nicht verfügbar"
case .authorizationDenied:
return "Zugriff auf Health-Daten wurde verweigert"
case .unsupportedDataType:
return "Dieser Datentyp wird nicht unterstützt"
case .noDataFound:
return "Keine Daten gefunden"
case .writeFailed(let error):
return "Schreiben fehlgeschlagen: \(error.localizedDescription)"
case .queryFailed(let error):
return "Abfrage fehlgeschlagen: \(error.localizedDescription)"
}
}
}
// MARK: - HKSource Extension
extension HKSource {
convenience init(bundleIdentifier: String, name: String) {
// Note: This is a workaround since HKSource doesn't have a public initializer
// In production, sources come from HealthKit queries
fatalError("HKSource cannot be initialized directly - use source from HKSample")
}
}
+287
View File
@@ -0,0 +1,287 @@
import Foundation
import Combine
// MARK: - Merge Engine
@MainActor
class MergeEngine: ObservableObject {
static let shared = MergeEngine()
private let ruleEngine = RuleEngine.shared
private let dataReader = DataReader.shared
@Published var pendingMerges: [MergeOperation] = []
@Published var completedMerges: [MergeOperation] = []
@Published var isMerging = false
@Published var mergeProgress: Double = 0
private init() {}
// MARK: - Analyze Window
func analyze(windowData: TimeWindowData) -> WindowAnalysis {
let readings = windowData.readings
let dataType = windowData.dataType
// No analysis needed for single reading
if readings.count <= 1 {
return WindowAnalysis(
windowData: windowData,
hasConflict: false,
conflictSeverity: nil,
recommendedReading: readings.first,
alternativeReadings: [],
confidence: .high,
analysisNotes: readings.isEmpty ? "Keine Daten" : "Einzelne Quelle"
)
}
// Apply rule to get recommendation
let result = ruleEngine.applyRule(to: readings, dataType: dataType)
// Calculate conflict severity
let values = readings.map { $0.value }.filter { $0 > 0 }
var severity: ConflictSeverity? = nil
if values.count >= 2, let min = values.min(), let max = values.max(), min > 0 {
let percentDiff = (max - min) / min * 100
if percentDiff >= 5 {
if percentDiff < 10 { severity = .minor }
else if percentDiff < 25 { severity = .moderate }
else if percentDiff < 50 { severity = .significant }
else { severity = .major }
}
}
let alternativeReadings = readings.filter { $0.id != result.selectedReading?.id }
return WindowAnalysis(
windowData: windowData,
hasConflict: windowData.hasConflict,
conflictSeverity: severity,
recommendedReading: result.selectedReading,
alternativeReadings: alternativeReadings,
confidence: result.confidence,
analysisNotes: result.reason
)
}
// MARK: - Resolve Conflict
func resolveConflict(_ conflict: Conflict, using result: RuleApplicationResult) -> ConflictResolution? {
guard let selectedReading = result.selectedReading else {
return nil
}
return ConflictResolution(
resolvedValue: selectedReading.value,
secondaryResolvedValue: selectedReading.secondaryValue,
winningSourceId: selectedReading.sourceId,
strategy: result.strategy,
isManual: result.strategy == .manual
)
}
func resolveConflictManually(
_ conflict: Conflict,
selectedReadingId: UUID
) -> ConflictResolution? {
guard let selectedReading = conflict.readings.first(where: { $0.id == selectedReadingId }) else {
return nil
}
return ConflictResolution(
resolvedValue: selectedReading.value,
secondaryResolvedValue: selectedReading.secondaryValue,
winningSourceId: selectedReading.sourceId,
strategy: .manual,
isManual: true
)
}
// MARK: - Create Merged Record
func createMergedRecord(from conflict: Conflict, resolution: ConflictResolution) -> MergedRecord {
return MergedRecord(
id: UUID(),
dataType: conflict.dataType,
timeWindow: conflict.timeWindow,
value: resolution.resolvedValue,
secondaryValue: resolution.secondaryResolvedValue,
originalSourceId: resolution.winningSourceId,
strategy: resolution.strategy,
createdAt: Date(),
metadata: [
"conflictId": conflict.id.uuidString,
"originalSourceCount": String(conflict.readings.count),
"isManualResolution": String(resolution.isManual)
]
)
}
// MARK: - Batch Processing
func processConflicts(_ conflicts: [Conflict]) async -> [MergeOperation] {
isMerging = true
defer { isMerging = false }
var operations: [MergeOperation] = []
for (index, conflict) in conflicts.enumerated() {
mergeProgress = Double(index) / Double(conflicts.count)
let result = ruleEngine.applyRule(to: conflict.readings, dataType: conflict.dataType)
if result.confidence == .requiresManual ||
ruleEngine.shouldRequestManualReview(readings: conflict.readings, dataType: conflict.dataType) {
// Add to pending for manual review
let operation = MergeOperation(
conflict: conflict,
status: .pendingManualReview,
result: result
)
pendingMerges.append(operation)
operations.append(operation)
} else if let resolution = resolveConflict(conflict, using: result) {
// Auto-resolve
let mergedRecord = createMergedRecord(from: conflict, resolution: resolution)
let operation = MergeOperation(
conflict: conflict,
status: .resolved,
result: result,
resolution: resolution,
mergedRecord: mergedRecord
)
completedMerges.append(operation)
operations.append(operation)
}
}
mergeProgress = 1.0
return operations
}
// MARK: - Daily Merge
func performDailyMerge(for date: Date) async throws -> DailyMergeReport {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
var report = DailyMergeReport(date: date)
for dataType in HealthDataType.allCases {
do {
let windowData = try await dataReader.fetchData(
for: dataType,
from: startOfDay,
to: endOfDay
)
let conflictWindows = windowData.filter { $0.hasConflict }
report.totalConflicts += conflictWindows.count
for window in conflictWindows {
let analysis = analyze(windowData: window)
if analysis.confidence == .requiresManual {
report.pendingManualReview += 1
} else {
report.autoResolved += 1
}
report.analysesByType[dataType, default: []].append(analysis)
}
} catch {
report.errors.append("Fehler bei \(dataType.displayName): \(error.localizedDescription)")
}
}
return report
}
}
// MARK: - Supporting Types
struct WindowAnalysis {
let windowData: TimeWindowData
let hasConflict: Bool
let conflictSeverity: ConflictSeverity?
let recommendedReading: SourceReading?
let alternativeReadings: [SourceReading]
let confidence: RuleConfidence
let analysisNotes: String
var recommendedValue: Double? {
recommendedReading?.value
}
var valueDifference: Double? {
guard let recommended = recommendedReading?.value,
let alternative = alternativeReadings.first?.value else {
return nil
}
return abs(recommended - alternative)
}
}
struct MergeOperation: Identifiable {
let id = UUID()
let conflict: Conflict
var status: MergeStatus
let result: RuleApplicationResult
var resolution: ConflictResolution?
var mergedRecord: MergedRecord?
let createdAt = Date()
var processedAt: Date?
enum MergeStatus {
case pending
case pendingManualReview
case resolved
case written
case failed
}
}
struct MergedRecord: Identifiable, Codable {
let id: UUID
let dataType: HealthDataType
let timeWindow: TimeWindow
let value: Double
let secondaryValue: Double?
let originalSourceId: String
let strategy: MergeStrategy
let createdAt: Date
var writtenAt: Date?
var healthKitRecordId: String?
var metadata: [String: String]
var formattedValue: String {
if value == floor(value) {
return String(format: "%.0f", value)
}
return String(format: "%.1f", value)
}
}
struct DailyMergeReport {
let date: Date
var totalConflicts = 0
var autoResolved = 0
var pendingManualReview = 0
var analysesByType: [HealthDataType: [WindowAnalysis]] = [:]
var errors: [String] = []
let generatedAt = Date()
var successRate: Double {
guard totalConflicts > 0 else { return 1.0 }
return Double(autoResolved) / Double(totalConflicts)
}
var summary: String {
if totalConflicts == 0 {
return "Keine Konflikte gefunden"
}
return "\(autoResolved)/\(totalConflicts) automatisch gelöst, \(pendingManualReview) zur Prüfung"
}
}
+571
View File
@@ -0,0 +1,571 @@
import Foundation
import Combine
// MARK: - Rule Engine
@MainActor
class RuleEngine: ObservableObject {
static let shared = RuleEngine()
@Published var rules: [HealthDataType: MergeRule] = [:]
@Published var isLoaded = false
private let storage = RuleStorageManager()
private let sourceManager = SourceManager.shared
private init() {
loadRules()
}
// MARK: - Rule Loading
func loadRules() {
let savedRules = storage.loadRules()
if savedRules.isEmpty {
// Initialize with defaults
for dataType in HealthDataType.allCases {
rules[dataType] = MergeRule.defaultRule(for: dataType)
}
saveRules()
} else {
rules = savedRules
}
isLoaded = true
}
func saveRules() {
storage.saveRules(rules)
}
// MARK: - Rule Access
func getRule(for dataType: HealthDataType) -> MergeRule {
return rules[dataType] ?? MergeRule.defaultRule(for: dataType)
}
func setRule(_ rule: MergeRule, for dataType: HealthDataType) {
rules[dataType] = rule
saveRules()
}
func resetToDefault(for dataType: HealthDataType) {
rules[dataType] = MergeRule.defaultRule(for: dataType)
saveRules()
}
func resetAllToDefaults() {
for dataType in HealthDataType.allCases {
rules[dataType] = MergeRule.defaultRule(for: dataType)
}
saveRules()
}
// MARK: - Rule Application
func applyRule(
to readings: [SourceReading],
dataType: HealthDataType
) -> RuleApplicationResult {
let rule = getRule(for: dataType)
// Filter out empty/zero readings for most strategies
let validReadings = readings.filter { $0.value > 0 || $0.quality == .complete }
guard !validReadings.isEmpty else {
return RuleApplicationResult(
selectedReading: nil,
strategy: rule.strategy,
confidence: .low,
reason: "Keine gültigen Werte vorhanden"
)
}
// If only one valid reading, no conflict
if validReadings.count == 1 {
return RuleApplicationResult(
selectedReading: validReadings[0],
strategy: rule.strategy,
confidence: .high,
reason: "Nur eine Quelle verfügbar"
)
}
// Apply strategy
switch rule.strategy {
case .exclusive:
return applyExclusiveStrategy(readings: validReadings, rule: rule)
case .priority:
return applyPriorityStrategy(readings: validReadings, rule: rule, dataType: dataType)
case .higherWins:
return applyHigherWinsStrategy(readings: validReadings, rule: rule)
case .lowerWins:
return applyLowerWinsStrategy(readings: validReadings, rule: rule)
case .average:
return applyAverageStrategy(readings: validReadings, rule: rule)
case .coverage:
return applyCoverageStrategy(readings: validReadings, rule: rule)
case .coverageThenHigher:
return applyCoverageThenHigherStrategy(readings: validReadings, rule: rule)
case .mostRecent:
return applyMostRecentStrategy(readings: validReadings, rule: rule)
case .manual:
return RuleApplicationResult(
selectedReading: nil,
strategy: .manual,
confidence: .requiresManual,
reason: "Manuelle Entscheidung erforderlich"
)
}
}
// MARK: - Strategy Implementations
private func applyExclusiveStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
// If primary source is specified, use it
if let primaryId = rule.primarySourceId,
let reading = readings.first(where: { $0.sourceId == primaryId }) {
return RuleApplicationResult(
selectedReading: reading,
strategy: .exclusive,
confidence: .high,
reason: "Exklusive Quelle: \(reading.sourceName)"
)
}
// Otherwise use highest priority source
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
if let first = sorted.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .exclusive,
confidence: .high,
reason: "Höchste Priorität: \(first.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .exclusive,
confidence: .low,
reason: "Keine geeignete Quelle gefunden"
)
}
private func applyPriorityStrategy(
readings: [SourceReading],
rule: MergeRule,
dataType: HealthDataType
) -> RuleApplicationResult {
// Sort by user-defined priority, then by category priority
let sorted = readings.sorted { r1, r2 in
let p1 = rule.sourcePriorities[r1.sourceId] ?? r1.sourceCategory.priority
let p2 = rule.sourcePriorities[r2.sourceId] ?? r2.sourceCategory.priority
return p1 > p2
}
if let first = sorted.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .priority,
confidence: .high,
reason: "Höchste Priorität: \(first.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .priority,
confidence: .low,
reason: "Keine Quelle mit Priorität gefunden"
)
}
private func applyHigherWinsStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
let sorted = readings.sorted { $0.value > $1.value }
if let highest = sorted.first {
// Check if there's a significant difference
let values = readings.map { $0.value }
let spread = (values.max() ?? 0) - (values.min() ?? 0)
let avgValue = values.reduce(0, +) / Double(values.count)
let spreadPercent = avgValue > 0 ? (spread / avgValue) * 100 : 0
let confidence: RuleConfidence = spreadPercent < 10 ? .high : .medium
return RuleApplicationResult(
selectedReading: highest,
strategy: .higherWins,
confidence: confidence,
reason: "Höchster Wert: \(highest.formattedValue) von \(highest.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .higherWins,
confidence: .low,
reason: "Keine Werte zum Vergleich"
)
}
private func applyLowerWinsStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
let sorted = readings.sorted { $0.value < $1.value }
if let lowest = sorted.first {
return RuleApplicationResult(
selectedReading: lowest,
strategy: .lowerWins,
confidence: .medium,
reason: "Niedrigster Wert: \(lowest.formattedValue) von \(lowest.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .lowerWins,
confidence: .low,
reason: "Keine Werte zum Vergleich"
)
}
private func applyAverageStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
let values = readings.map { $0.value }
let average = values.reduce(0, +) / Double(values.count)
// Create a synthetic reading for the average
let syntheticReading = SourceReading(
sourceId: HealthBridgeConstants.bundleIdentifier,
sourceName: "Durchschnitt",
sourceCategory: .healthBridge,
value: average,
timestamp: readings.first?.timestamp ?? Date(),
quality: .complete
)
return RuleApplicationResult(
selectedReading: syntheticReading,
strategy: .average,
confidence: .medium,
reason: "Durchschnitt aus \(readings.count) Quellen"
)
}
private func applyCoverageStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
// Prefer readings with complete quality
let completeReadings = readings.filter { $0.quality == .complete }
if completeReadings.count == 1 {
return RuleApplicationResult(
selectedReading: completeReadings[0],
strategy: .coverage,
confidence: .high,
reason: "Einzige Quelle mit vollständigen Daten: \(completeReadings[0].sourceName)"
)
}
// If multiple complete readings, fall back to priority
if !completeReadings.isEmpty {
let sorted = completeReadings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
if let first = sorted.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .coverage,
confidence: .medium,
reason: "Mehrere Quellen verfügbar, gewählt: \(first.sourceName)"
)
}
}
// No complete readings, use any reading with highest priority
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
if let first = sorted.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .coverage,
confidence: .low,
reason: "Keine vollständigen Daten, gewählt: \(first.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .coverage,
confidence: .low,
reason: "Keine Quelle mit Daten gefunden"
)
}
private func applyCoverageThenHigherStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
// First check if one source has data and others don't (coverage)
let nonZeroReadings = readings.filter { $0.value > 0 }
let zeroReadings = readings.filter { $0.value == 0 }
// If only one source has data, it wins on coverage
if nonZeroReadings.count == 1 && !zeroReadings.isEmpty {
return RuleApplicationResult(
selectedReading: nonZeroReadings[0],
strategy: .coverageThenHigher,
confidence: .high,
reason: "Einzige Quelle mit Daten: \(nonZeroReadings[0].sourceName)"
)
}
// Multiple sources have data, use higher wins
if nonZeroReadings.count > 1 {
let sorted = nonZeroReadings.sorted { $0.value > $1.value }
if let highest = sorted.first {
return RuleApplicationResult(
selectedReading: highest,
strategy: .coverageThenHigher,
confidence: .medium,
reason: "Höherer Wert bei Konflikt: \(highest.formattedValue) von \(highest.sourceName)"
)
}
}
// Fallback
if let first = readings.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .coverageThenHigher,
confidence: .low,
reason: "Fallback auf erste Quelle"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .coverageThenHigher,
confidence: .low,
reason: "Keine Daten verfügbar"
)
}
private func applyMostRecentStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
let sorted = readings.sorted { $0.timestamp > $1.timestamp }
if let mostRecent = sorted.first {
return RuleApplicationResult(
selectedReading: mostRecent,
strategy: .mostRecent,
confidence: .high,
reason: "Neuester Wert von \(mostRecent.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .mostRecent,
confidence: .low,
reason: "Keine Zeitstempel verfügbar"
)
}
// MARK: - Threshold Check
func shouldRequestManualReview(
readings: [SourceReading],
dataType: HealthDataType
) -> Bool {
let rule = getRule(for: dataType)
guard let threshold = rule.thresholdForManualReview else {
return rule.strategy == .manual
}
let values = readings.map { $0.value }.filter { $0 > 0 }
guard values.count >= 2,
let min = values.min(),
let max = values.max(),
min > 0 else {
return false
}
let percentDiff = (max - min) / min * 100
return percentDiff > threshold
}
}
// MARK: - Rule Application Result
struct RuleApplicationResult {
let selectedReading: SourceReading?
let strategy: MergeStrategy
let confidence: RuleConfidence
let reason: String
var resolvedValue: Double? {
selectedReading?.value
}
var winningSourceId: String? {
selectedReading?.sourceId
}
}
enum RuleConfidence: String, Codable {
case high = "high"
case medium = "medium"
case low = "low"
case requiresManual = "requires_manual"
var displayName: String {
switch self {
case .high: return "Hohe Sicherheit"
case .medium: return "Mittlere Sicherheit"
case .low: return "Geringe Sicherheit"
case .requiresManual: return "Manuelle Prüfung"
}
}
var icon: String {
switch self {
case .high: return "checkmark.seal.fill"
case .medium: return "checkmark.seal"
case .low: return "questionmark.circle"
case .requiresManual: return "hand.raised.fill"
}
}
}
// MARK: - Rule Storage Manager
class RuleStorageManager {
private let userDefaults = UserDefaults.standard
private let rulesKey = "healthbridge.merge.rules"
func saveRules(_ rules: [HealthDataType: MergeRule]) {
do {
let data = try JSONEncoder().encode(rules)
userDefaults.set(data, forKey: rulesKey)
} catch {
print("Failed to save rules: \(error)")
}
}
func loadRules() -> [HealthDataType: MergeRule] {
guard let data = userDefaults.data(forKey: rulesKey) else {
return [:]
}
do {
return try JSONDecoder().decode([HealthDataType: MergeRule].self, from: data)
} catch {
print("Failed to load rules: \(error)")
return [:]
}
}
}
// MARK: - Blood Pressure Handler
class BloodPressureHandler {
static let shared = BloodPressureHandler()
struct ValidationResult {
let isValid: Bool
let issues: [String]
}
func validate(systolic: Double, diastolic: Double) -> ValidationResult {
var issues: [String] = []
// Range validation
if systolic < 70 || systolic > 200 {
issues.append("Systolischer Wert ausserhalb des Normalbereichs (70-200 mmHg)")
}
if diastolic < 40 || diastolic > 130 {
issues.append("Diastolischer Wert ausserhalb des Normalbereichs (40-130 mmHg)")
}
// Plausibility check
if diastolic >= systolic {
issues.append("Diastolischer Wert muss kleiner als systolischer Wert sein")
}
if systolic - diastolic < 20 {
issues.append("Pulsdruck zu gering (< 20 mmHg)")
}
if systolic - diastolic > 100 {
issues.append("Pulsdruck zu hoch (> 100 mmHg)")
}
return ValidationResult(isValid: issues.isEmpty, issues: issues)
}
func classifyBloodPressure(systolic: Double, diastolic: Double) -> BloodPressureClassification {
if systolic < 120 && diastolic < 80 {
return .normal
} else if systolic < 130 && diastolic < 80 {
return .elevated
} else if systolic < 140 || diastolic < 90 {
return .hypertensionStage1
} else if systolic < 180 || diastolic < 120 {
return .hypertensionStage2
} else {
return .hypertensiveCrisis
}
}
enum BloodPressureClassification: String {
case normal = "Normal"
case elevated = "Erhöht"
case hypertensionStage1 = "Bluthochdruck Stufe 1"
case hypertensionStage2 = "Bluthochdruck Stufe 2"
case hypertensiveCrisis = "Hypertensive Krise"
var color: String {
switch self {
case .normal: return "green"
case .elevated: return "yellow"
case .hypertensionStage1: return "orange"
case .hypertensionStage2: return "red"
case .hypertensiveCrisis: return "purple"
}
}
var recommendation: String {
switch self {
case .normal:
return "Weiter so! Regelmässige Kontrolle empfohlen."
case .elevated:
return "Lebensstiländerungen empfohlen. Mehr Bewegung, weniger Salz."
case .hypertensionStage1:
return "Arztbesuch empfohlen. Möglicherweise Medikation erforderlich."
case .hypertensionStage2:
return "Zeitnaher Arztbesuch erforderlich. Medikation wahrscheinlich notwendig."
case .hypertensiveCrisis:
return "SOFORT medizinische Hilfe aufsuchen!"
}
}
}
}
+409
View File
@@ -0,0 +1,409 @@
import Foundation
import HealthKit
import Combine
// MARK: - Source Manager
@MainActor
class SourceManager: ObservableObject {
static let shared = SourceManager()
@Published var sources: [HealthSource] = []
@Published var sourceProfiles: [String: SourceProfile] = [:]
@Published var isDiscovering = false
private let healthKitManager = HealthKitManager.shared
private let storage = SourceStorageManager()
private init() {}
// MARK: - Source Discovery
func discoverSources() async {
isDiscovering = true
defer { isDiscovering = false }
await healthKitManager.discoverSources()
sources = healthKitManager.discoveredSources
// Load saved source profiles and merge with discovered sources
let savedProfiles = storage.loadSourceProfiles()
for source in sources {
if let savedProfile = savedProfiles[source.bundleIdentifier] {
sourceProfiles[source.bundleIdentifier] = savedProfile
} else {
sourceProfiles[source.bundleIdentifier] = SourceProfile(source: source)
}
}
}
// MARK: - Source Classification
func classifySource(_ bundleIdentifier: String) -> SourceCategory {
let lowercased = bundleIdentifier.lowercased()
// HealthBridge
if lowercased.contains("healthbridge") {
return .healthBridge
}
// Apple Devices
if lowercased.contains("com.apple") {
if lowercased.contains("watch") || lowercased.contains("nano") {
return .watch
}
if lowercased.contains("health") {
return .iPhone
}
}
// Known Watch Brands
let watchBrands = ["huawei", "samsung", "galaxy", "fitbit", "garmin", "polar",
"withings", "amazfit", "xiaomi", "honor", "oppo", "suunto",
"coros", "whoop", "oura"]
for brand in watchBrands {
if lowercased.contains(brand) {
return .thirdPartyWatch
}
}
// Known Health Apps
let healthApps = ["strava", "nike", "adidas", "runtastic", "runkeeper",
"mapmyrun", "endomondo", "myfitnesspal", "flo", "clue"]
for app in healthApps {
if lowercased.contains(app) {
return .thirdPartyApp
}
}
return .unknown
}
// MARK: - Source Capabilities
func getSourceCapabilities(_ source: HealthSource) -> SourceCapabilities {
let category = source.category
switch category {
case .iPhone:
return SourceCapabilities(
canMeasureSteps: true,
canMeasureDistance: true,
canMeasureFloors: true,
canMeasureHeartRate: false,
canMeasureBloodPressure: false,
canMeasureBloodOxygen: false,
canMeasureSleep: false,
canMeasureWorkouts: true,
hasGPS: true,
hasBarometer: true,
hasAccelerometer: true
)
case .watch:
return SourceCapabilities(
canMeasureSteps: true,
canMeasureDistance: true,
canMeasureFloors: false,
canMeasureHeartRate: true,
canMeasureBloodPressure: false,
canMeasureBloodOxygen: true,
canMeasureSleep: true,
canMeasureWorkouts: true,
hasGPS: true,
hasBarometer: false,
hasAccelerometer: true
)
case .thirdPartyWatch:
// Check for specific features based on name
let name = source.name.lowercased()
let isHuaweiD2 = name.contains("huawei") && (name.contains("d2") || name.contains("watch d"))
return SourceCapabilities(
canMeasureSteps: true,
canMeasureDistance: true,
canMeasureFloors: false,
canMeasureHeartRate: true,
canMeasureBloodPressure: isHuaweiD2, // Huawei Watch D2 has BP sensor
canMeasureBloodOxygen: true,
canMeasureSleep: true,
canMeasureWorkouts: true,
hasGPS: true,
hasBarometer: false,
hasAccelerometer: true
)
case .thirdPartyApp:
return SourceCapabilities(
canMeasureSteps: true,
canMeasureDistance: true,
canMeasureFloors: false,
canMeasureHeartRate: false,
canMeasureBloodPressure: false,
canMeasureBloodOxygen: false,
canMeasureSleep: false,
canMeasureWorkouts: true,
hasGPS: true,
hasBarometer: false,
hasAccelerometer: false
)
case .healthBridge, .unknown:
return SourceCapabilities.empty
}
}
// MARK: - Source Health
func getSourceHealth(_ source: HealthSource) async -> SourceHealthReport {
var dataGaps: [HealthDataType: [TimeWindow]] = [:]
var lastActivityByType: [HealthDataType: Date] = [:]
var recordCountByType: [HealthDataType: Int] = [:]
let calendar = Calendar.current
let now = Date()
let yesterday = calendar.date(byAdding: .day, value: -1, to: now)!
for dataType in source.supportedDataTypes {
do {
let samples = try await healthKitManager.fetchSamples(
for: dataType,
from: yesterday,
to: now
)
let matchingSamples = samples.filter {
$0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier
}
recordCountByType[dataType] = matchingSamples.count
if let lastSample = matchingSamples.last {
lastActivityByType[dataType] = lastSample.endDate
}
// Detect gaps
let gaps = detectDataGaps(
in: matchingSamples,
from: yesterday,
to: now,
expectedInterval: 15 * 60 // 15 minutes
)
if !gaps.isEmpty {
dataGaps[dataType] = gaps
}
} catch {
print("Error checking health for \(dataType): \(error)")
}
}
let overallQuality: DataQuality
if recordCountByType.values.reduce(0, +) == 0 {
overallQuality = .missing
} else if dataGaps.isEmpty {
overallQuality = .complete
} else {
overallQuality = .partial
}
return SourceHealthReport(
source: source,
lastActivityByType: lastActivityByType,
recordCountByType: recordCountByType,
dataGaps: dataGaps,
overallQuality: overallQuality,
checkedAt: Date()
)
}
private func detectDataGaps(
in samples: [HKSample],
from start: Date,
to end: Date,
expectedInterval: TimeInterval
) -> [TimeWindow] {
guard !samples.isEmpty else {
return [TimeWindow(start: start, end: end)]
}
var gaps: [TimeWindow] = []
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
// Check gap at beginning
if let firstSample = sortedSamples.first,
firstSample.startDate.timeIntervalSince(start) > expectedInterval * 2 {
gaps.append(TimeWindow(start: start, end: firstSample.startDate))
}
// Check gaps between samples
for i in 0..<(sortedSamples.count - 1) {
let currentEnd = sortedSamples[i].endDate
let nextStart = sortedSamples[i + 1].startDate
let gap = nextStart.timeIntervalSince(currentEnd)
if gap > expectedInterval * 2 {
gaps.append(TimeWindow(start: currentEnd, end: nextStart))
}
}
// Check gap at end
if let lastSample = sortedSamples.last,
end.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
gaps.append(TimeWindow(start: lastSample.endDate, end: end))
}
return gaps
}
// MARK: - Priority Management
func setPriority(_ priority: Int, for source: HealthSource, dataType: HealthDataType) {
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
profile.priorities[dataType] = priority
sourceProfiles[source.bundleIdentifier] = profile
storage.saveSourceProfiles(sourceProfiles)
}
func getPriority(for source: HealthSource, dataType: HealthDataType) -> Int {
if let profile = sourceProfiles[source.bundleIdentifier],
let priority = profile.priorities[dataType] {
return priority
}
return source.category.priority
}
func getSourcesByPriority(for dataType: HealthDataType) -> [HealthSource] {
return sources
.filter { $0.supportedDataTypes.contains(dataType) }
.sorted { getPriority(for: $0, dataType: dataType) > getPriority(for: $1, dataType: dataType) }
}
// MARK: - Source Enable/Disable
func setEnabled(_ enabled: Bool, for source: HealthSource) {
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
profile.isEnabled = enabled
sourceProfiles[source.bundleIdentifier] = profile
storage.saveSourceProfiles(sourceProfiles)
}
func isEnabled(_ source: HealthSource) -> Bool {
return sourceProfiles[source.bundleIdentifier]?.isEnabled ?? true
}
}
// MARK: - Source Profile
struct SourceProfile: Codable {
let bundleIdentifier: String
var priorities: [HealthDataType: Int]
var isEnabled: Bool
var customName: String?
var notes: String?
let addedAt: Date
init(source: HealthSource) {
self.bundleIdentifier = source.bundleIdentifier
self.priorities = [:]
self.isEnabled = true
self.customName = nil
self.notes = nil
self.addedAt = Date()
}
}
// MARK: - Source Capabilities
struct SourceCapabilities {
let canMeasureSteps: Bool
let canMeasureDistance: Bool
let canMeasureFloors: Bool
let canMeasureHeartRate: Bool
let canMeasureBloodPressure: Bool
let canMeasureBloodOxygen: Bool
let canMeasureSleep: Bool
let canMeasureWorkouts: Bool
let hasGPS: Bool
let hasBarometer: Bool
let hasAccelerometer: Bool
static let empty = SourceCapabilities(
canMeasureSteps: false,
canMeasureDistance: false,
canMeasureFloors: false,
canMeasureHeartRate: false,
canMeasureBloodPressure: false,
canMeasureBloodOxygen: false,
canMeasureSleep: false,
canMeasureWorkouts: false,
hasGPS: false,
hasBarometer: false,
hasAccelerometer: false
)
var supportedDataTypes: Set<HealthDataType> {
var types = Set<HealthDataType>()
if canMeasureSteps { types.insert(.steps) }
if canMeasureDistance { types.insert(.distance) }
if canMeasureFloors { types.insert(.floorsClimbed) }
if canMeasureHeartRate {
types.insert(.heartRate)
types.insert(.restingHeartRate)
}
if canMeasureBloodPressure {
types.insert(.bloodPressureSystolic)
types.insert(.bloodPressureDiastolic)
}
if canMeasureBloodOxygen { types.insert(.bloodOxygen) }
if canMeasureSleep { types.insert(.sleep) }
return types
}
}
// MARK: - Source Health Report
struct SourceHealthReport {
let source: HealthSource
let lastActivityByType: [HealthDataType: Date]
let recordCountByType: [HealthDataType: Int]
let dataGaps: [HealthDataType: [TimeWindow]]
let overallQuality: DataQuality
let checkedAt: Date
var lastOverallActivity: Date? {
lastActivityByType.values.max()
}
var totalRecordCount: Int {
recordCountByType.values.reduce(0, +)
}
var hasSignificantGaps: Bool {
dataGaps.values.flatMap { $0 }.contains { $0.duration > 3600 } // > 1 hour gap
}
}
// MARK: - Source Storage Manager
class SourceStorageManager {
private let userDefaults = UserDefaults.standard
private let profilesKey = "healthbridge.source.profiles"
func saveSourceProfiles(_ profiles: [String: SourceProfile]) {
do {
let data = try JSONEncoder().encode(profiles)
userDefaults.set(data, forKey: profilesKey)
} catch {
print("Failed to save source profiles: \(error)")
}
}
func loadSourceProfiles() -> [String: SourceProfile] {
guard let data = userDefaults.data(forKey: profilesKey) else {
return [:]
}
do {
return try JSONDecoder().decode([String: SourceProfile].self, from: data)
} catch {
print("Failed to load source profiles: \(error)")
return [:]
}
}
}
+301
View File
@@ -0,0 +1,301 @@
import Foundation
import Combine
import UserNotifications
// MARK: - Sync Coordinator
@MainActor
class SyncCoordinator: ObservableObject {
static let shared = SyncCoordinator()
private let healthKitManager = HealthKitManager.shared
private let sourceManager = SourceManager.shared
private let dataReader = DataReader.shared
private let ruleEngine = RuleEngine.shared
private let mergeEngine = MergeEngine.shared
private let dataWriter = DataWriter.shared
@Published var isSyncing = false
@Published var syncProgress: Double = 0
@Published var lastSyncDate: Date?
@Published var lastSyncResult: SyncResult?
@Published var pendingConflicts: [Conflict] = []
@Published var syncHistory: [SyncResult] = []
private let syncHistoryKey = "healthbridge.sync.history"
private let maxHistoryItems = 50
private init() {
loadSyncHistory()
}
// MARK: - Main Sync
func performSync(
for date: Date = Date(),
dataTypes: [HealthDataType] = HealthDataType.allCases
) async throws {
guard !isSyncing else { return }
isSyncing = true
syncProgress = 0
defer { isSyncing = false }
let startTime = Date()
var result = SyncResult(startedAt: startTime)
do {
// Step 1: Refresh sources (10%)
syncProgress = 0.05
await sourceManager.discoverSources()
syncProgress = 0.1
// Step 2: Detect conflicts (40%)
let conflicts = try await dataReader.detectConflicts(for: date, dataTypes: dataTypes)
result.totalConflicts = conflicts.count
syncProgress = 0.4
// Step 3: Process conflicts with merge engine (70%)
let operations = await mergeEngine.processConflicts(conflicts)
syncProgress = 0.7
let autoResolved = operations.filter { $0.status == .resolved }
let pendingManual = operations.filter { $0.status == .pendingManualReview }
result.autoResolved = autoResolved.count
result.pendingManualReview = pendingManual.count
// Update pending conflicts
pendingConflicts = pendingManual.map { $0.conflict }
// Step 4: Write resolved records (90%)
let recordsToWrite = autoResolved.compactMap { $0.mergedRecord }
if !recordsToWrite.isEmpty {
let writeResult = await dataWriter.writeBatch(recordsToWrite)
result.writtenRecords = writeResult.successCount
result.writeErrors = writeResult.failureCount
}
syncProgress = 0.9
// Step 5: Finalize (100%)
result.completedAt = Date()
result.status = .success
syncProgress = 1.0
} catch {
result.status = .failed
result.error = error.localizedDescription
result.completedAt = Date()
throw error
}
lastSyncDate = Date()
lastSyncResult = result
addToHistory(result)
// Send notification if there are pending conflicts
if result.pendingManualReview > 0 {
await sendConflictNotification(count: result.pendingManualReview)
}
}
// MARK: - Quick Sync
func performQuickSync() async throws {
try await performSync(
for: Date(),
dataTypes: [.steps, .heartRate, .activeEnergy]
)
}
// MARK: - Sync Specific Data Type
func syncDataType(_ dataType: HealthDataType, for date: Date = Date()) async throws {
try await performSync(for: date, dataTypes: [dataType])
}
// MARK: - Manual Conflict Resolution
func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async throws {
guard let resolution = mergeEngine.resolveConflictManually(conflict, selectedReadingId: selectedReadingId) else {
throw SyncError.resolutionFailed
}
var resolvedConflict = conflict
resolvedConflict.status = .resolved
resolvedConflict.resolution = resolution
resolvedConflict.resolvedAt = Date()
let mergedRecord = mergeEngine.createMergedRecord(from: resolvedConflict, resolution: resolution)
// Write the record
_ = try await dataWriter.writeRecord(mergedRecord)
// Remove from pending
pendingConflicts.removeAll { $0.id == conflict.id }
}
func ignoreConflict(_ conflict: Conflict) {
pendingConflicts.removeAll { $0.id == conflict.id }
}
// MARK: - Sync History
private func loadSyncHistory() {
guard let data = UserDefaults.standard.data(forKey: syncHistoryKey),
let history = try? JSONDecoder().decode([SyncResult].self, from: data) else {
return
}
syncHistory = history
}
private func addToHistory(_ result: SyncResult) {
syncHistory.insert(result, at: 0)
if syncHistory.count > maxHistoryItems {
syncHistory = Array(syncHistory.prefix(maxHistoryItems))
}
saveSyncHistory()
}
private func saveSyncHistory() {
guard let data = try? JSONEncoder().encode(syncHistory) else { return }
UserDefaults.standard.set(data, forKey: syncHistoryKey)
}
func clearHistory() {
syncHistory.removeAll()
UserDefaults.standard.removeObject(forKey: syncHistoryKey)
}
// MARK: - Notifications
private func sendConflictNotification(count: Int) async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized else { return }
let content = UNMutableNotificationContent()
content.title = "HealthBridge"
content.body = count == 1
? "1 Konflikt erfordert Ihre Aufmerksamkeit"
: "\(count) Konflikte erfordern Ihre Aufmerksamkeit"
content.sound = .default
content.badge = NSNumber(value: count)
let request = UNNotificationRequest(
identifier: "healthbridge.conflicts",
content: content,
trigger: nil
)
try? await center.add(request)
}
func requestNotificationPermission() async -> Bool {
let center = UNUserNotificationCenter.current()
do {
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
} catch {
return false
}
}
// MARK: - Statistics
var todayStats: TodayStats {
let today = Calendar.current.startOfDay(for: Date())
let todaySyncs = syncHistory.filter {
Calendar.current.isDate($0.startedAt, inSameDayAs: today)
}
return TodayStats(
syncCount: todaySyncs.count,
totalConflicts: todaySyncs.reduce(0) { $0 + $1.totalConflicts },
autoResolved: todaySyncs.reduce(0) { $0 + $1.autoResolved },
pendingManual: pendingConflicts.count,
lastSync: lastSyncDate
)
}
}
// MARK: - Supporting Types
struct SyncResult: Identifiable, Codable {
let id = UUID()
let startedAt: Date
var completedAt: Date?
var status: SyncStatus = .inProgress
var totalConflicts = 0
var autoResolved = 0
var pendingManualReview = 0
var writtenRecords = 0
var writeErrors = 0
var error: String?
enum SyncStatus: String, Codable {
case inProgress = "in_progress"
case success = "success"
case partialSuccess = "partial_success"
case failed = "failed"
}
var duration: TimeInterval? {
guard let completed = completedAt else { return nil }
return completed.timeIntervalSince(startedAt)
}
var formattedDuration: String {
guard let duration = duration else { return "" }
if duration < 1 {
return "< 1s"
}
return String(format: "%.1fs", duration)
}
var successRate: Double {
guard totalConflicts > 0 else { return 1.0 }
return Double(autoResolved) / Double(totalConflicts)
}
}
struct TodayStats {
let syncCount: Int
let totalConflicts: Int
let autoResolved: Int
let pendingManual: Int
let lastSync: Date?
var resolutionRate: Double {
guard totalConflicts > 0 else { return 1.0 }
return Double(autoResolved) / Double(totalConflicts)
}
var formattedLastSync: String {
guard let date = lastSync else { return "Nie" }
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
enum SyncError: LocalizedError {
case notAuthorized
case syncInProgress
case resolutionFailed
case writeFailed
var errorDescription: String? {
switch self {
case .notAuthorized:
return "Keine Berechtigung für HealthKit"
case .syncInProgress:
return "Synchronisierung läuft bereits"
case .resolutionFailed:
return "Konfliktauflösung fehlgeschlagen"
case .writeFailed:
return "Schreiben der Daten fehlgeschlagen"
}
}
}
+153
View File
@@ -0,0 +1,153 @@
import Foundation
import SwiftUI
// MARK: - Date Extensions
extension Date {
var startOfDay: Date {
Calendar.current.startOfDay(for: self)
}
var endOfDay: Date {
Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!.addingTimeInterval(-1)
}
var isToday: Bool {
Calendar.current.isDateInToday(self)
}
var isYesterday: Bool {
Calendar.current.isDateInYesterday(self)
}
func formatted(style: DateFormatter.Style) -> String {
let formatter = DateFormatter()
formatter.dateStyle = style
formatter.timeStyle = .none
return formatter.string(from: self)
}
func formattedTime() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter.string(from: self)
}
func formattedRelative() -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: self, relativeTo: Date())
}
func adding(days: Int) -> Date {
Calendar.current.date(byAdding: .day, value: days, to: self)!
}
func adding(hours: Int) -> Date {
Calendar.current.date(byAdding: .hour, value: hours, to: self)!
}
func adding(minutes: Int) -> Date {
Calendar.current.date(byAdding: .minute, value: minutes, to: self)!
}
}
// MARK: - Double Extensions
extension Double {
func formatted(decimals: Int = 1) -> String {
String(format: "%.\(decimals)f", self)
}
var formattedAsInteger: String {
String(format: "%.0f", self)
}
var formattedAsPercentage: String {
String(format: "%.1f%%", self * 100)
}
}
// MARK: - Array Extensions
extension Array {
func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}
}
// MARK: - Color Extensions
extension Color {
static let healthBridgePrimary = Color.blue
static let healthBridgeSecondary = Color.cyan
static let healthBridgeAccent = Color.orange
static func forSeverity(_ severity: ConflictSeverity) -> Color {
switch severity {
case .minor: return .green
case .moderate: return .yellow
case .significant: return .orange
case .major: return .red
}
}
static func forQuality(_ quality: DataQuality) -> Color {
switch quality {
case .complete: return .green
case .partial: return .yellow
case .missing: return .gray
case .invalid: return .red
}
}
}
// MARK: - View Extensions
extension View {
func cardStyle() -> some View {
self
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
func sectionHeader(_ title: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
self
}
}
}
// MARK: - Binding Extensions
extension Binding {
func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
self.wrappedValue = newValue
handler(newValue)
}
)
}
}
// MARK: - Optional Extensions
extension Optional where Wrapped == String {
var orEmpty: String {
self ?? ""
}
var isNilOrEmpty: Bool {
self?.isEmpty ?? true
}
}
// MARK: - Collection Extensions
extension Collection {
var isNotEmpty: Bool {
!isEmpty
}
}
@@ -0,0 +1,185 @@
import Foundation
import UserNotifications
@MainActor
class NotificationManager: ObservableObject {
static let shared = NotificationManager()
@Published var isAuthorized = false
@Published var pendingNotifications: [String] = []
private let center = UNUserNotificationCenter.current()
private init() {
Task {
await checkAuthorization()
}
}
// MARK: - Authorization
func requestAuthorization() async -> Bool {
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
isAuthorized = granted
return granted
} catch {
print("Notification authorization failed: \(error)")
return false
}
}
func checkAuthorization() async {
let settings = await center.notificationSettings()
isAuthorized = settings.authorizationStatus == .authorized
}
// MARK: - Conflict Notifications
func sendConflictNotification(count: Int) async {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = "HealthBridge"
if count == 1 {
content.body = "1 neuer Konflikt erfordert Ihre Aufmerksamkeit"
} else {
content.body = "\(count) neue Konflikte erfordern Ihre Aufmerksamkeit"
}
content.sound = .default
content.badge = NSNumber(value: count)
content.categoryIdentifier = "CONFLICT"
let request = UNNotificationRequest(
identifier: "healthbridge.conflict.\(Date().timeIntervalSince1970)",
content: content,
trigger: nil
)
do {
try await center.add(request)
} catch {
print("Failed to send notification: \(error)")
}
}
// MARK: - Sync Notifications
func sendSyncCompleteNotification(
conflictsResolved: Int,
pendingConflicts: Int
) async {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = "Sync abgeschlossen"
if pendingConflicts > 0 {
content.body = "\(conflictsResolved) Konflikte gelöst, \(pendingConflicts) offen"
} else {
content.body = "Alle \(conflictsResolved) Konflikte wurden gelöst"
}
content.sound = .default
content.categoryIdentifier = "SYNC_COMPLETE"
let request = UNNotificationRequest(
identifier: "healthbridge.sync.\(Date().timeIntervalSince1970)",
content: content,
trigger: nil
)
do {
try await center.add(request)
} catch {
print("Failed to send notification: \(error)")
}
}
// MARK: - Scheduled Notifications
func scheduleReminder(at hour: Int, minute: Int) async {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = "HealthBridge Erinnerung"
content.body = "Vergessen Sie nicht, Ihre Gesundheitsdaten zu synchronisieren"
content.sound = .default
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
let request = UNNotificationRequest(
identifier: "healthbridge.reminder.daily",
content: content,
trigger: trigger
)
do {
try await center.add(request)
} catch {
print("Failed to schedule reminder: \(error)")
}
}
func cancelReminder() {
center.removePendingNotificationRequests(withIdentifiers: ["healthbridge.reminder.daily"])
}
// MARK: - Badge Management
func clearBadge() async {
do {
try await center.setBadgeCount(0)
} catch {
print("Failed to clear badge: \(error)")
}
}
func updateBadge(count: Int) async {
do {
try await center.setBadgeCount(count)
} catch {
print("Failed to update badge: \(error)")
}
}
// MARK: - Notification Categories
func registerCategories() {
// Conflict category with actions
let resolveAction = UNNotificationAction(
identifier: "RESOLVE_AUTO",
title: "Automatisch lösen",
options: []
)
let viewAction = UNNotificationAction(
identifier: "VIEW_CONFLICTS",
title: "Anzeigen",
options: [.foreground]
)
let conflictCategory = UNNotificationCategory(
identifier: "CONFLICT",
actions: [resolveAction, viewAction],
intentIdentifiers: [],
options: []
)
// Sync complete category
let syncCategory = UNNotificationCategory(
identifier: "SYNC_COMPLETE",
actions: [viewAction],
intentIdentifiers: [],
options: []
)
center.setNotificationCategories([conflictCategory, syncCategory])
}
}
@@ -0,0 +1,103 @@
import Foundation
import Combine
@MainActor
class DashboardViewModel: ObservableObject {
private let syncCoordinator = SyncCoordinator.shared
private let dataReader = DataReader.shared
private let sourceManager = SourceManager.shared
@Published var dailySummary: DailySummary?
@Published var isLoading = false
@Published var selectedDate = Date()
@Published var errorMessage: String?
private var cancellables = Set<AnyCancellable>()
init() {
setupBindings()
}
private func setupBindings() {
$selectedDate
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] date in
Task {
await self?.loadData(for: date)
}
}
.store(in: &cancellables)
}
func loadData(for date: Date = Date()) async {
isLoading = true
errorMessage = nil
do {
dailySummary = try await dataReader.fetchDailySummary(for: date)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func performSync() async {
do {
try await syncCoordinator.performSync(for: selectedDate)
await loadData(for: selectedDate)
} catch {
errorMessage = "Sync fehlgeschlagen: \(error.localizedDescription)"
}
}
func refresh() async {
await sourceManager.discoverSources()
await loadData(for: selectedDate)
}
var syncStatus: SyncStatus {
if syncCoordinator.isSyncing {
return .syncing
} else if let lastSync = syncCoordinator.lastSyncDate {
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
if hoursSinceSync < 1 {
return .synced
} else if hoursSinceSync < 24 {
return .stale
} else {
return .veryStale
}
} else {
return .neverSynced
}
}
enum SyncStatus {
case syncing
case synced
case stale
case veryStale
case neverSynced
var description: String {
switch self {
case .syncing: return "Synchronisiere..."
case .synced: return "Synchronisiert"
case .stale: return "Sync empfohlen"
case .veryStale: return "Sync überfällig"
case .neverSynced: return "Nie synchronisiert"
}
}
var icon: String {
switch self {
case .syncing: return "arrow.triangle.2.circlepath"
case .synced: return "checkmark.circle.fill"
case .stale: return "exclamationmark.circle"
case .veryStale: return "exclamationmark.triangle"
case .neverSynced: return "xmark.circle"
}
}
}
}
@@ -0,0 +1,241 @@
import SwiftUI
import Charts
struct HealthChart: View {
let dataType: HealthDataType
let data: [ChartDataPoint]
let showConflicts: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
Text(dataType.displayName)
.font(.headline)
Spacer()
if let latest = data.last {
Text(latest.formattedValue)
.font(.title3)
.fontWeight(.semibold)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if #available(iOS 16.0, *) {
Chart(data) { point in
LineMark(
x: .value("Zeit", point.date),
y: .value("Wert", point.value)
)
.foregroundStyle(.blue)
PointMark(
x: .value("Zeit", point.date),
y: .value("Wert", point.value)
)
.foregroundStyle(point.hasConflict && showConflicts ? .orange : .blue)
.symbolSize(point.hasConflict && showConflicts ? 100 : 50)
}
.frame(height: 150)
.chartXAxis {
AxisMarks(values: .stride(by: .hour, count: 4)) { value in
AxisValueLabel(format: .dateTime.hour())
}
}
.chartYAxis {
AxisMarks(position: .leading)
}
} else {
// Fallback for iOS 15
simpleChart
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var simpleChart: some View {
GeometryReader { geometry in
let maxValue = data.map { $0.value }.max() ?? 1
let minValue = data.map { $0.value }.min() ?? 0
let range = maxValue - minValue
Path { path in
guard !data.isEmpty else { return }
let xStep = geometry.size.width / CGFloat(max(1, data.count - 1))
for (index, point) in data.enumerated() {
let x = CGFloat(index) * xStep
let normalizedY = range > 0 ? (point.value - minValue) / range : 0.5
let y = geometry.size.height * (1 - normalizedY)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(Color.blue, lineWidth: 2)
}
.frame(height: 150)
}
}
struct ChartDataPoint: Identifiable {
let id = UUID()
let date: Date
let value: Double
let hasConflict: Bool
let sourceId: String?
var formattedValue: String {
if value == floor(value) {
return String(format: "%.0f", value)
}
return String(format: "%.1f", value)
}
}
// MARK: - Blood Pressure Chart
struct BloodPressureChart: View {
let data: [BloodPressurePoint]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "drop.fill")
.foregroundStyle(.red)
Text("Blutdruck")
.font(.headline)
Spacer()
if let latest = data.last {
Text("\(Int(latest.systolic))/\(Int(latest.diastolic))")
.font(.title3)
.fontWeight(.semibold)
Text("mmHg")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if #available(iOS 16.0, *) {
Chart(data) { point in
// Systolic
LineMark(
x: .value("Zeit", point.date),
y: .value("Systolisch", point.systolic)
)
.foregroundStyle(.red)
// Diastolic
LineMark(
x: .value("Zeit", point.date),
y: .value("Diastolisch", point.diastolic)
)
.foregroundStyle(.blue)
}
.frame(height: 150)
.chartYScale(domain: 40...180)
.chartLegend(position: .bottom)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
struct BloodPressurePoint: Identifiable {
let id = UUID()
let date: Date
let systolic: Double
let diastolic: Double
let classification: BloodPressureHandler.BloodPressureClassification
}
// MARK: - Summary Ring
struct SummaryRing: View {
let progress: Double
let color: Color
let icon: String
let value: String
let label: String
var body: some View {
VStack(spacing: 4) {
ZStack {
Circle()
.stroke(color.opacity(0.2), lineWidth: 8)
Circle()
.trim(from: 0, to: min(progress, 1.0))
.stroke(color, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 2) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(color)
Text(value)
.font(.caption2)
.fontWeight(.semibold)
}
}
.frame(width: 60, height: 60)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
#Preview {
VStack(spacing: 20) {
HealthChart(
dataType: .steps,
data: (0..<24).map { hour in
ChartDataPoint(
date: Calendar.current.date(byAdding: .hour, value: hour, to: Calendar.current.startOfDay(for: Date()))!,
value: Double.random(in: 0...1000),
hasConflict: hour % 5 == 0,
sourceId: nil
)
},
showConflicts: true
)
HStack(spacing: 20) {
SummaryRing(
progress: 0.75,
color: .blue,
icon: "figure.walk",
value: "7.5k",
label: "Schritte"
)
SummaryRing(
progress: 0.5,
color: .red,
icon: "heart.fill",
value: "72",
label: "Puls"
)
SummaryRing(
progress: 1.0,
color: .green,
icon: "checkmark.circle",
value: "100%",
label: "Synced"
)
}
}
.padding()
}
+348
View File
@@ -0,0 +1,348 @@
import SwiftUI
struct ConflictsView: View {
@EnvironmentObject var syncCoordinator: SyncCoordinator
@State private var selectedConflict: Conflict?
@State private var showingDetail = false
var body: some View {
NavigationStack {
Group {
if syncCoordinator.pendingConflicts.isEmpty {
emptyState
} else {
conflictsList
}
}
.navigationTitle("Konflikte")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Alle automatisch lösen") {
Task { await resolveAllAuto() }
}
Button("Alle ignorieren", role: .destructive) {
ignoreAll()
}
} label: {
Image(systemName: "ellipsis.circle")
}
.disabled(syncCoordinator.pendingConflicts.isEmpty)
}
}
.sheet(item: $selectedConflict) { conflict in
ConflictDetailView(conflict: conflict) { selectedReadingId in
Task {
await resolveConflict(conflict, selectedReadingId: selectedReadingId)
}
}
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
Text("Keine Konflikte")
.font(.title2)
.fontWeight(.semibold)
Text("Alle Ihre Gesundheitsdaten sind synchronisiert")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
// MARK: - Conflicts List
private var conflictsList: some View {
List {
ForEach(groupedConflicts.keys.sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
Section(dataType.displayName) {
ForEach(groupedConflicts[dataType] ?? []) { conflict in
ConflictRow(conflict: conflict)
.contentShape(Rectangle())
.onTapGesture {
selectedConflict = conflict
}
}
}
}
}
.listStyle(.insetGrouped)
}
private var groupedConflicts: [HealthDataType: [Conflict]] {
Dictionary(grouping: syncCoordinator.pendingConflicts, by: { $0.dataType })
}
// MARK: - Actions
private func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async {
do {
try await syncCoordinator.resolveConflict(conflict, selectedReadingId: selectedReadingId)
selectedConflict = nil
} catch {
print("Failed to resolve conflict: \(error)")
}
}
private func resolveAllAuto() async {
for conflict in syncCoordinator.pendingConflicts {
if let primaryReading = conflict.primarySourceReading {
try? await syncCoordinator.resolveConflict(conflict, selectedReadingId: primaryReading.id)
}
}
}
private func ignoreAll() {
for conflict in syncCoordinator.pendingConflicts {
syncCoordinator.ignoreConflict(conflict)
}
}
}
// MARK: - Conflict Row
struct ConflictRow: View {
let conflict: Conflict
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: conflict.dataType.icon)
.foregroundStyle(.blue)
Text(conflict.timeWindow.formattedRange)
.font(.headline)
Spacer()
severityBadge
}
HStack(spacing: 16) {
ForEach(conflict.readings.prefix(3)) { reading in
VStack(alignment: .leading, spacing: 2) {
Text(reading.sourceName)
.font(.caption)
.foregroundStyle(.secondary)
Text(reading.formattedValue)
.font(.subheadline)
.fontWeight(.medium)
}
}
}
if conflict.readings.count > 3 {
Text("+\(conflict.readings.count - 3) weitere")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private var severityBadge: some View {
Text(conflict.severity.displayName)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(severityColor.opacity(0.2))
.foregroundStyle(severityColor)
.clipShape(Capsule())
}
private var severityColor: Color {
switch conflict.severity {
case .minor: return .green
case .moderate: return .yellow
case .significant: return .orange
case .major: return .red
}
}
}
// MARK: - Conflict Detail View
struct ConflictDetailView: View {
let conflict: Conflict
let onResolve: (UUID) -> Void
@Environment(\.dismiss) private var dismiss
@State private var selectedReadingId: UUID?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
headerSection
// Readings
readingsSection
// Difference Info
differenceSection
Spacer()
}
.padding()
}
.navigationTitle("Konflikt lösen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Auswählen") {
if let id = selectedReadingId {
onResolve(id)
}
}
.disabled(selectedReadingId == nil)
}
}
}
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 8) {
Image(systemName: conflict.dataType.icon)
.font(.largeTitle)
.foregroundStyle(.blue)
Text(conflict.dataType.displayName)
.font(.title2)
.fontWeight(.semibold)
Text(conflict.timeWindow.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(conflict.timeWindow.formattedRange)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
// MARK: - Readings
private var readingsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Quellen")
.font(.headline)
ForEach(conflict.readings) { reading in
ReadingCard(
reading: reading,
isSelected: selectedReadingId == reading.id,
dataType: conflict.dataType
)
.onTapGesture {
selectedReadingId = reading.id
}
}
}
}
// MARK: - Difference
private var differenceSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Analyse")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("Differenz")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f %@", conflict.valueDifference, conflict.dataType.unit))
.font(.title3)
.fontWeight(.medium)
}
Spacer()
VStack(alignment: .trailing) {
Text("Prozentual")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f%%", conflict.percentageDifference))
.font(.title3)
.fontWeight(.medium)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Reading Card
struct ReadingCard: View {
let reading: SourceReading
let isSelected: Bool
let dataType: HealthDataType
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: reading.sourceCategory.icon)
.foregroundStyle(.blue)
Text(reading.sourceName)
.font(.headline)
}
Text(reading.sourceCategory.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(reading.formattedValue)
.font(.title2)
.fontWeight(.semibold)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? .blue : .secondary)
}
.padding()
.background(isSelected ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
}
#Preview {
ConflictsView()
.environmentObject(SyncCoordinator.shared)
}
+42
View File
@@ -0,0 +1,42 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var syncCoordinator: SyncCoordinator
var body: some View {
TabView(selection: $appState.selectedTab) {
DashboardView()
.tabItem {
Label("Dashboard", systemImage: "chart.bar.fill")
}
.tag(AppState.Tab.dashboard)
ConflictsView()
.tabItem {
Label("Konflikte", systemImage: "arrow.triangle.2.circlepath")
}
.tag(AppState.Tab.conflicts)
.badge(syncCoordinator.pendingConflicts.count)
RulesView()
.tabItem {
Label("Regeln", systemImage: "slider.horizontal.3")
}
.tag(AppState.Tab.rules)
SourcesView()
.tabItem {
Label("Quellen", systemImage: "antenna.radiowaves.left.and.right")
}
.tag(AppState.Tab.sources)
}
.tint(.blue)
}
}
#Preview {
ContentView()
.environmentObject(AppState())
.environmentObject(SyncCoordinator.shared)
}
+379
View File
@@ -0,0 +1,379 @@
import SwiftUI
struct DashboardView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var syncCoordinator: SyncCoordinator
@StateObject private var dataReader = DataReader.shared
@State private var dailySummary: DailySummary?
@State private var isLoading = false
@State private var selectedDate = Date()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Sync Status Card
syncStatusCard
// Date Picker
datePicker
// Health Metrics
if let summary = dailySummary {
healthMetricsGrid(summary: summary)
} else if isLoading {
loadingView
} else {
emptyStateView
}
// Pending Conflicts Alert
if !syncCoordinator.pendingConflicts.isEmpty {
pendingConflictsCard
}
// Recent Sync History
recentSyncHistory
}
.padding()
}
.navigationTitle("HealthBridge")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await performSync() }
} label: {
if syncCoordinator.isSyncing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(syncCoordinator.isSyncing)
}
}
.refreshable {
await loadData()
}
.task {
await loadData()
}
.onChange(of: selectedDate) {
Task { await loadData() }
}
}
}
// MARK: - Sync Status Card
private var syncStatusCard: some View {
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Heute")
.font(.headline)
Text(syncCoordinator.todayStats.formattedLastSync)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if syncCoordinator.isSyncing {
VStack(alignment: .trailing) {
ProgressView(value: syncCoordinator.syncProgress)
.frame(width: 60)
Text("Synchronisiere...")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.green)
}
}
Divider()
HStack(spacing: 20) {
StatItem(
value: "\(syncCoordinator.todayStats.syncCount)",
label: "Syncs",
icon: "arrow.triangle.2.circlepath"
)
StatItem(
value: "\(syncCoordinator.todayStats.totalConflicts)",
label: "Konflikte",
icon: "exclamationmark.triangle"
)
StatItem(
value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%",
label: "Gelöst",
icon: "checkmark.circle"
)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
}
// MARK: - Date Picker
private var datePicker: some View {
DatePicker(
"Datum",
selection: $selectedDate,
in: ...Date(),
displayedComponents: .date
)
.datePickerStyle(.compact)
.padding(.horizontal)
}
// MARK: - Health Metrics Grid
private func healthMetricsGrid(summary: DailySummary) -> some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
ForEach(HealthDataType.allCases) { dataType in
if let value = summary.values[dataType], value > 0 {
HealthMetricCard(
dataType: dataType,
value: summary.formattedValue(for: dataType),
conflictCount: summary.conflictCounts[dataType] ?? 0
)
}
}
}
}
// MARK: - Pending Conflicts Card
private var pendingConflictsCard: some View {
Button {
appState.selectedTab = .conflicts
} label: {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text("\(syncCoordinator.pendingConflicts.count) Konflikte zu prüfen")
.font(.headline)
Text("Tippen zum Anzeigen")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
// MARK: - Recent Sync History
private var recentSyncHistory: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Letzte Synchronisierungen")
.font(.headline)
ForEach(syncCoordinator.syncHistory.prefix(5)) { result in
SyncHistoryRow(result: result)
}
if syncCoordinator.syncHistory.isEmpty {
Text("Keine Synchronisierungen")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
}
// MARK: - Loading & Empty States
private var loadingView: some View {
VStack(spacing: 12) {
ProgressView()
Text("Lade Daten...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var emptyStateView: some View {
VStack(spacing: 12) {
Image(systemName: "heart.text.square")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Keine Daten verfügbar")
.font(.headline)
Text("Synchronisieren Sie, um Daten zu laden")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
// MARK: - Actions
private func loadData() async {
isLoading = true
defer { isLoading = false }
do {
dailySummary = try await dataReader.fetchDailySummary(for: selectedDate)
} catch {
print("Failed to load data: \(error)")
}
}
private func performSync() async {
do {
try await syncCoordinator.performSync(for: selectedDate)
await loadData()
} catch {
print("Sync failed: \(error)")
}
}
}
// MARK: - Supporting Views
struct StatItem: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.title3)
.fontWeight(.semibold)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
struct HealthMetricCard: View {
let dataType: HealthDataType
let value: String
let conflictCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
Spacer()
if conflictCount > 0 {
Text("\(conflictCount)")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.clipShape(Capsule())
}
}
Text(value)
.font(.title2)
.fontWeight(.semibold)
Text(dataType.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
}
struct SyncHistoryRow: View {
let result: SyncResult
var body: some View {
HStack {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
VStack(alignment: .leading, spacing: 2) {
Text(formattedDate)
.font(.subheadline)
Text("\(result.autoResolved)/\(result.totalConflicts) gelöst")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(result.formattedDuration)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private var statusIcon: String {
switch result.status {
case .success: return "checkmark.circle.fill"
case .partialSuccess: return "exclamationmark.circle.fill"
case .failed: return "xmark.circle.fill"
case .inProgress: return "arrow.clockwise"
}
}
private var statusColor: Color {
switch result.status {
case .success: return .green
case .partialSuccess: return .orange
case .failed: return .red
case .inProgress: return .blue
}
}
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: result.startedAt)
}
}
#Preview {
DashboardView()
.environmentObject(AppState())
.environmentObject(SyncCoordinator.shared)
}
+252
View File
@@ -0,0 +1,252 @@
import SwiftUI
struct RulesView: View {
@StateObject private var ruleEngine = RuleEngine.shared
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedDataType: HealthDataType?
@State private var showingResetConfirmation = false
var body: some View {
NavigationStack {
List {
Section {
Text("Regeln bestimmen, wie Konflikte zwischen verschiedenen Datenquellen automatisch gelöst werden.")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(HealthDataType.allCases) { dataType in
RuleRow(
dataType: dataType,
rule: ruleEngine.getRule(for: dataType)
)
.contentShape(Rectangle())
.onTapGesture {
selectedDataType = dataType
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Regeln")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Alle zurücksetzen", role: .destructive) {
showingResetConfirmation = true
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(item: $selectedDataType) { dataType in
RuleEditorView(dataType: dataType)
}
.confirmationDialog(
"Alle Regeln zurücksetzen?",
isPresented: $showingResetConfirmation,
titleVisibility: .visible
) {
Button("Zurücksetzen", role: .destructive) {
ruleEngine.resetAllToDefaults()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Alle Regeln werden auf die Standardwerte zurückgesetzt.")
}
}
}
}
// MARK: - Rule Row
struct RuleRow: View {
let dataType: HealthDataType
let rule: MergeRule
var body: some View {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(dataType.displayName)
.font(.headline)
HStack(spacing: 8) {
Image(systemName: rule.strategy.icon)
.font(.caption)
Text(rule.strategy.displayName)
.font(.caption)
}
.foregroundStyle(.secondary)
}
Spacer()
if !rule.autoApply {
Image(systemName: "hand.raised.fill")
.foregroundStyle(.orange)
.font(.caption)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
// MARK: - Rule Editor View
struct RuleEditorView: View {
let dataType: HealthDataType
@Environment(\.dismiss) private var dismiss
@StateObject private var ruleEngine = RuleEngine.shared
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedStrategy: MergeStrategy
@State private var autoApply: Bool
@State private var primarySourceId: String?
@State private var thresholdForManualReview: Double?
@State private var useThreshold: Bool
init(dataType: HealthDataType) {
self.dataType = dataType
let rule = RuleEngine.shared.getRule(for: dataType)
_selectedStrategy = State(initialValue: rule.strategy)
_autoApply = State(initialValue: rule.autoApply)
_primarySourceId = State(initialValue: rule.primarySourceId)
_thresholdForManualReview = State(initialValue: rule.thresholdForManualReview)
_useThreshold = State(initialValue: rule.thresholdForManualReview != nil)
}
var body: some View {
NavigationStack {
Form {
// Data Type Info
Section {
HStack {
Image(systemName: dataType.icon)
.font(.title2)
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text(dataType.displayName)
.font(.headline)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Strategy Selection
Section("Strategie") {
Picker("Merge-Strategie", selection: $selectedStrategy) {
ForEach(MergeStrategy.allCases) { strategy in
Label(strategy.displayName, systemImage: strategy.icon)
.tag(strategy)
}
}
.pickerStyle(.navigationLink)
Text(selectedStrategy.description)
.font(.caption)
.foregroundStyle(.secondary)
}
// Primary Source (for exclusive/priority)
if selectedStrategy == .exclusive || selectedStrategy == .priority {
Section("Primäre Quelle") {
let sources = sourceManager.sources.filter {
$0.supportedDataTypes.contains(dataType)
}
if sources.isEmpty {
Text("Keine Quellen für diesen Datentyp")
.foregroundStyle(.secondary)
} else {
Picker("Quelle", selection: $primarySourceId) {
Text("Automatisch").tag(nil as String?)
ForEach(sources) { source in
Text(source.displayName).tag(source.bundleIdentifier as String?)
}
}
}
}
}
// Auto Apply
Section("Automatisierung") {
Toggle("Automatisch anwenden", isOn: $autoApply)
if autoApply {
Toggle("Schwellenwert für manuelle Prüfung", isOn: $useThreshold)
if useThreshold {
VStack(alignment: .leading) {
Text("Bei Differenz über \(Int(thresholdForManualReview ?? 20))% nachfragen")
.font(.caption)
.foregroundStyle(.secondary)
Slider(
value: Binding(
get: { thresholdForManualReview ?? 20 },
set: { thresholdForManualReview = $0 }
),
in: 5...50,
step: 5
)
}
}
}
}
// Reset
Section {
Button("Auf Standard zurücksetzen", role: .destructive) {
let defaultRule = MergeRule.defaultRule(for: dataType)
selectedStrategy = defaultRule.strategy
autoApply = defaultRule.autoApply
primarySourceId = defaultRule.primarySourceId
thresholdForManualReview = defaultRule.thresholdForManualReview
useThreshold = defaultRule.thresholdForManualReview != nil
}
}
}
.navigationTitle("Regel bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Speichern") {
saveRule()
dismiss()
}
}
}
}
}
private func saveRule() {
let rule = MergeRule(
dataType: dataType,
strategy: selectedStrategy,
primarySourceId: primarySourceId,
autoApply: autoApply,
thresholdForManualReview: useThreshold ? thresholdForManualReview : nil
)
ruleEngine.setRule(rule, for: dataType)
}
}
#Preview {
RulesView()
}
+218
View File
@@ -0,0 +1,218 @@
import SwiftUI
struct SettingsView: View {
@AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true
@AppStorage("syncIntervalMinutes") private var syncIntervalMinutes = 15
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
@AppStorage("notifyOnConflict") private var notifyOnConflict = true
@AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false
@AppStorage("autoResolveMinorConflicts") private var autoResolveMinorConflicts = true
@StateObject private var syncCoordinator = SyncCoordinator.shared
@StateObject private var healthKitManager = HealthKitManager.shared
@State private var showingClearDataConfirmation = false
@State private var showingExportSheet = false
var body: some View {
NavigationStack {
Form {
// Sync Settings
Section("Synchronisierung") {
Toggle("Hintergrund-Sync", isOn: $backgroundSyncEnabled)
if backgroundSyncEnabled {
Picker("Intervall", selection: $syncIntervalMinutes) {
Text("15 Minuten").tag(15)
Text("30 Minuten").tag(30)
Text("1 Stunde").tag(60)
Text("2 Stunden").tag(120)
}
}
Toggle("Kleine Konflikte automatisch lösen", isOn: $autoResolveMinorConflicts)
}
// Notification Settings
Section("Benachrichtigungen") {
Toggle("Benachrichtigungen aktivieren", isOn: $notificationsEnabled)
if notificationsEnabled {
Toggle("Bei neuen Konflikten", isOn: $notifyOnConflict)
Toggle("Nach Synchronisierung", isOn: $notifyOnSyncComplete)
}
}
// Health Status
Section("HealthKit Status") {
HStack {
Text("Autorisierung")
Spacer()
if healthKitManager.isAuthorized {
Label("Erteilt", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Label("Ausstehend", systemImage: "exclamationmark.circle")
.foregroundStyle(.orange)
}
}
if !healthKitManager.isAuthorized {
Button("HealthKit-Zugriff anfordern") {
Task {
try? await healthKitManager.requestAuthorization()
}
}
}
}
// Statistics
Section("Statistiken") {
StatRow(label: "Syncs heute", value: "\(syncCoordinator.todayStats.syncCount)")
StatRow(label: "Konflikte heute", value: "\(syncCoordinator.todayStats.totalConflicts)")
StatRow(label: "Automatisch gelöst", value: "\(syncCoordinator.todayStats.autoResolved)")
StatRow(label: "Auflösungsrate", value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%")
}
// Data Management
Section("Daten") {
Button("Sync-Verlauf exportieren") {
showingExportSheet = true
}
Button("Sync-Verlauf löschen", role: .destructive) {
showingClearDataConfirmation = true
}
}
// About
Section("Info") {
HStack {
Text("Version")
Spacer()
Text("1.0.0")
.foregroundStyle(.secondary)
}
Link(destination: URL(string: "https://apple.com/health")!) {
HStack {
Text("Apple Health")
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Einstellungen")
.confirmationDialog(
"Sync-Verlauf löschen?",
isPresented: $showingClearDataConfirmation,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
syncCoordinator.clearHistory()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Der gesamte Sync-Verlauf wird gelöscht. Dies kann nicht rückgängig gemacht werden.")
}
.sheet(isPresented: $showingExportSheet) {
ExportView()
}
}
}
}
// MARK: - Stat Row
struct StatRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Export View
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var syncCoordinator = SyncCoordinator.shared
@State private var exportFormat: ExportFormat = .json
@State private var isExporting = false
enum ExportFormat: String, CaseIterable {
case json = "JSON"
case csv = "CSV"
}
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Daten exportieren")
.font(.title2)
.fontWeight(.semibold)
Picker("Format", selection: $exportFormat) {
ForEach(ExportFormat.allCases, id: \.self) { format in
Text(format.rawValue).tag(format)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
Text("\(syncCoordinator.syncHistory.count) Sync-Einträge")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Button {
exportData()
} label: {
Label("Exportieren", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding()
.disabled(isExporting || syncCoordinator.syncHistory.isEmpty)
}
.padding()
.navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
}
}
}
private func exportData() {
isExporting = true
// In a real app, this would create and share a file
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
isExporting = false
dismiss()
}
}
}
#Preview {
SettingsView()
}
+407
View File
@@ -0,0 +1,407 @@
import SwiftUI
struct SourcesView: View {
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedSource: HealthSource?
@State private var isRefreshing = false
var body: some View {
NavigationStack {
Group {
if sourceManager.sources.isEmpty && !sourceManager.isDiscovering {
emptyState
} else {
sourcesList
}
}
.navigationTitle("Quellen")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await refreshSources() }
} label: {
if isRefreshing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(isRefreshing)
}
}
.sheet(item: $selectedSource) { source in
SourceDetailView(source: source)
}
.task {
if sourceManager.sources.isEmpty {
await refreshSources()
}
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("Keine Quellen gefunden")
.font(.title2)
.fontWeight(.semibold)
Text("Verbinden Sie Geräte mit Apple Health")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
Task { await refreshSources() }
} label: {
Label("Aktualisieren", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
}
.padding()
}
// MARK: - Sources List
private var sourcesList: some View {
List {
ForEach(groupedSources.keys.sorted(by: { $0.priority > $1.priority }), id: \.self) { category in
Section(category.displayName) {
ForEach(groupedSources[category] ?? []) { source in
SourceRow(source: source)
.contentShape(Rectangle())
.onTapGesture {
selectedSource = source
}
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await refreshSources()
}
}
private var groupedSources: [SourceCategory: [HealthSource]] {
Dictionary(grouping: sourceManager.sources, by: { $0.category })
}
private func refreshSources() async {
isRefreshing = true
defer { isRefreshing = false }
await sourceManager.discoverSources()
}
}
// MARK: - Source Row
struct SourceRow: View {
let source: HealthSource
@StateObject private var sourceManager = SourceManager.shared
var body: some View {
HStack {
Image(systemName: source.category.icon)
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 40)
VStack(alignment: .leading, spacing: 4) {
Text(source.displayName)
.font(.headline)
Text("\(source.supportedDataTypes.count) Datentypen")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let status = sourceManager.sourceHealthStatus[source.id] {
Image(systemName: status.syncStatus.icon)
.foregroundStyle(statusColor(for: status.syncStatus))
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private func statusColor(for status: SourceHealthStatus.SyncStatus) -> Color {
switch status {
case .recentlySynced: return .green
case .syncedToday: return .blue
case .stale: return .orange
case .veryStale, .neverSynced: return .red
}
}
}
// MARK: - Source Detail View
struct SourceDetailView: View {
let source: HealthSource
@Environment(\.dismiss) private var dismiss
@StateObject private var sourceManager = SourceManager.shared
@State private var healthReport: SourceHealthReport?
@State private var isLoading = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
headerSection
// Capabilities
capabilitiesSection
// Data Types
dataTypesSection
// Health Report
if let report = healthReport {
healthReportSection(report)
}
// Priority Settings
prioritySection
}
.padding()
}
.navigationTitle(source.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") {
dismiss()
}
}
}
.task {
await loadHealthReport()
}
}
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 12) {
Image(systemName: source.category.icon)
.font(.system(size: 48))
.foregroundStyle(.blue)
Text(source.displayName)
.font(.title2)
.fontWeight(.semibold)
Text(source.category.displayName)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(source.bundleIdentifier)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding()
}
// MARK: - Capabilities
private var capabilitiesSection: some View {
let capabilities = sourceManager.getSourceCapabilities(source)
return VStack(alignment: .leading, spacing: 12) {
Text("Fähigkeiten")
.font(.headline)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
CapabilityBadge(name: "Schritte", available: capabilities.canMeasureSteps)
CapabilityBadge(name: "Herzfrequenz", available: capabilities.canMeasureHeartRate)
CapabilityBadge(name: "Blutdruck", available: capabilities.canMeasureBloodPressure)
CapabilityBadge(name: "SpO2", available: capabilities.canMeasureBloodOxygen)
CapabilityBadge(name: "Schlaf", available: capabilities.canMeasureSleep)
CapabilityBadge(name: "GPS", available: capabilities.hasGPS)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Data Types
private var dataTypesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Unterstützte Datentypen")
.font(.headline)
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
.frame(width: 24)
Text(dataType.displayName)
Spacer()
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Health Report
private func healthReportSection(_ report: SourceHealthReport) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Zustand")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("Datensätze (24h)")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(report.totalRecordCount)")
.font(.title3)
.fontWeight(.medium)
}
Spacer()
VStack(alignment: .trailing) {
Text("Qualität")
.font(.caption)
.foregroundStyle(.secondary)
Image(systemName: report.overallQuality.icon)
.font(.title3)
.foregroundStyle(qualityColor(report.overallQuality))
}
}
if let lastActivity = report.lastOverallActivity {
HStack {
Text("Letzte Aktivität")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(formattedDate(lastActivity))
.font(.subheadline)
}
}
if report.hasSignificantGaps {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Datenlücken erkannt")
.font(.subheadline)
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Priority Section
private var prioritySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Priorität")
.font(.headline)
Text("Höhere Priorität bedeutet, dass Daten dieser Quelle bevorzugt werden")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
HStack {
Text(dataType.displayName)
Spacer()
Stepper(
"\(sourceManager.getPriority(for: source, dataType: dataType))",
value: Binding(
get: { sourceManager.getPriority(for: source, dataType: dataType) },
set: { sourceManager.setPriority($0, for: source, dataType: dataType) }
),
in: 0...100,
step: 10
)
.frame(width: 150)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Helpers
private func loadHealthReport() async {
isLoading = true
defer { isLoading = false }
healthReport = await sourceManager.getSourceHealth(source)
}
private func qualityColor(_ quality: DataQuality) -> Color {
switch quality {
case .complete: return .green
case .partial: return .yellow
case .missing: return .gray
case .invalid: return .red
}
}
private func formattedDate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
// MARK: - Capability Badge
struct CapabilityBadge: View {
let name: String
let available: Bool
var body: some View {
HStack(spacing: 4) {
Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle")
.foregroundStyle(available ? .green : .secondary)
Text(name)
.font(.caption)
.foregroundStyle(available ? .primary : .secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(available ? Color.green.opacity(0.1) : Color.gray.opacity(0.1))
.clipShape(Capsule())
}
}
#Preview {
SourcesView()
}
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="de" data-lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromoMaster - Produkt-Promotion-Tool | Product Promotion Tool</title>
<!-- SEO Meta Tags -->
<meta name="description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch. Generate professional product promotions in German and English.">
<meta name="keywords" content="Produktwerbung, Product Promotion, Marketing, Werbung, Advertising, Social Media, SEO, Product Launch">
<meta name="author" content="PromoMaster">
<meta name="robots" content="index, follow">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content="PromoMaster - Automatische Produktwerbung">
<meta property="og:description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch.">
<meta property="og:locale" content="de_DE">
<meta property="og:locale:alternate" content="en_US">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="PromoMaster - Product Promotion Tool">
<meta name="twitter:description" content="Generate professional product promotions in German and English automatically.">
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "PromoMaster",
"description": "Automatisches Produkt-Promotion-Tool in Deutsch und Englisch",
"applicationCategory": "Marketing",
"operatingSystem": "Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Language Toggle -->
<div class="lang-toggle">
<button id="btn-de" class="lang-btn active" onclick="setLanguage('de')">DE</button>
<button id="btn-en" class="lang-btn" onclick="setLanguage('en')">EN</button>
</div>
<!-- Hero Section -->
<header class="hero">
<div class="hero-bg"></div>
<div class="container">
<h1 class="hero-title">
<span class="hero-icon">&#9889;</span>
PromoMaster
</h1>
<p class="hero-subtitle" data-de="Dein Produkt. Weltweit bekannt. Vollautomatisch." data-en="Your Product. Worldwide Fame. Fully Automatic.">Dein Produkt. Weltweit bekannt. Vollautomatisch.</p>
</div>
</header>
<!-- Main App -->
<main class="container">
<!-- Step 1: Product Input -->
<section class="card step-card" id="step1">
<div class="step-header">
<span class="step-number">1</span>
<h2 data-de="Produkt-Informationen" data-en="Product Information"></h2>
</div>
<div class="form-grid">
<div class="form-group">
<label for="productName" data-de="Produktname *" data-en="Product Name *"></label>
<input type="text" id="productName" data-de-placeholder="z.B. SuperWidget Pro" data-en-placeholder="e.g. SuperWidget Pro">
</div>
<div class="form-group">
<label for="productCategory" data-de="Kategorie" data-en="Category"></label>
<select id="productCategory">
<option value="tech" data-de="Technologie" data-en="Technology"></option>
<option value="fashion" data-de="Mode & Bekleidung" data-en="Fashion & Apparel"></option>
<option value="food" data-de="Lebensmittel & Getr&auml;nke" data-en="Food & Beverages"></option>
<option value="health" data-de="Gesundheit & Wellness" data-en="Health & Wellness"></option>
<option value="home" data-de="Haus & Garten" data-en="Home & Garden"></option>
<option value="sport" data-de="Sport & Fitness" data-en="Sports & Fitness"></option>
<option value="beauty" data-de="Sch&ouml;nheit & Pflege" data-en="Beauty & Care"></option>
<option value="education" data-de="Bildung & Kurse" data-en="Education & Courses"></option>
<option value="software" data-de="Software & Apps" data-en="Software & Apps"></option>
<option value="other" data-de="Sonstiges" data-en="Other"></option>
</select>
</div>
<div class="form-group full-width">
<label for="productDescription" data-de="Kurzbeschreibung *" data-en="Short Description *"></label>
<textarea id="productDescription" rows="3" data-de-placeholder="Was macht dein Produkt besonders? (max. 200 Zeichen)" data-en-placeholder="What makes your product special? (max. 200 characters)" maxlength="200"></textarea>
<span class="char-count"><span id="charCount">0</span>/200</span>
</div>
<div class="form-group">
<label for="productPrice" data-de="Preis (optional)" data-en="Price (optional)"></label>
<input type="text" id="productPrice" data-de-placeholder="z.B. 29,99 EUR" data-en-placeholder="e.g. $29.99">
</div>
<div class="form-group">
<label for="productUrl" data-de="Website / Link (optional)" data-en="Website / Link (optional)"></label>
<input type="url" id="productUrl" data-de-placeholder="https://dein-produkt.de" data-en-placeholder="https://your-product.com">
</div>
<div class="form-group full-width">
<label for="productFeatures" data-de="Top-Features (kommagetrennt)" data-en="Top Features (comma-separated)"></label>
<input type="text" id="productFeatures" data-de-placeholder="z.B. Schnell, Zuverl&auml;ssig, Einfach zu bedienen" data-en-placeholder="e.g. Fast, Reliable, Easy to use">
</div>
<div class="form-group full-width">
<label for="targetAudience" data-de="Zielgruppe" data-en="Target Audience"></label>
<input type="text" id="targetAudience" data-de-placeholder="z.B. Unternehmer, Studenten, Eltern" data-en-placeholder="e.g. Entrepreneurs, Students, Parents">
</div>
</div>
</section>
<!-- Step 2: Promotion Style -->
<section class="card step-card" id="step2">
<div class="step-header">
<span class="step-number">2</span>
<h2 data-de="Werbestil w&auml;hlen" data-en="Choose Promotion Style"></h2>
</div>
<div class="style-grid">
<div class="style-option selected" data-style="professional" onclick="selectStyle(this)">
<span class="style-icon">&#128188;</span>
<span class="style-label" data-de="Professionell" data-en="Professional"></span>
</div>
<div class="style-option" data-style="casual" onclick="selectStyle(this)">
<span class="style-icon">&#128075;</span>
<span class="style-label" data-de="Locker & Freundlich" data-en="Casual & Friendly"></span>
</div>
<div class="style-option" data-style="urgent" onclick="selectStyle(this)">
<span class="style-icon">&#9889;</span>
<span class="style-label" data-de="Dringend & FOMO" data-en="Urgent & FOMO"></span>
</div>
<div class="style-option" data-style="luxury" onclick="selectStyle(this)">
<span class="style-icon">&#10024;</span>
<span class="style-label" data-de="Premium & Luxus" data-en="Premium & Luxury"></span>
</div>
<div class="style-option" data-style="fun" onclick="selectStyle(this)">
<span class="style-icon">&#127881;</span>
<span class="style-label" data-de="Spa&szlig;ig & Kreativ" data-en="Fun & Creative"></span>
</div>
<div class="style-option" data-style="minimal" onclick="selectStyle(this)">
<span class="style-icon">&#9711;</span>
<span class="style-label" data-de="Minimalistisch" data-en="Minimalist"></span>
</div>
</div>
</section>
<!-- Generate Button -->
<div class="generate-section">
<button class="btn-generate" onclick="generateAll()" id="generateBtn">
<span class="btn-icon">&#9889;</span>
<span data-de="Alle Werbematerialien generieren" data-en="Generate All Promotion Materials"></span>
</button>
</div>
<!-- Results Section -->
<section id="results" class="results-section hidden">
<!-- Social Media Posts -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128241; Social Media Posts" data-en="&#128241; Social Media Posts"></h3>
<button class="btn-copy-all" onclick="copyAll('social')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="result-tabs">
<button class="tab-btn active" onclick="switchTab(this, 'twitter')">Twitter/X</button>
<button class="tab-btn" onclick="switchTab(this, 'instagram')">Instagram</button>
<button class="tab-btn" onclick="switchTab(this, 'facebook')">Facebook</button>
<button class="tab-btn" onclick="switchTab(this, 'linkedin')">LinkedIn</button>
<button class="tab-btn" onclick="switchTab(this, 'tiktok')">TikTok</button>
</div>
<div class="tab-content" id="tab-twitter">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="twitter-de"></div>
<button class="btn-copy" onclick="copyText('twitter-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="twitter-en"></div>
<button class="btn-copy" onclick="copyText('twitter-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-instagram">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="instagram-de"></div>
<button class="btn-copy" onclick="copyText('instagram-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="instagram-en"></div>
<button class="btn-copy" onclick="copyText('instagram-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-facebook">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="facebook-de"></div>
<button class="btn-copy" onclick="copyText('facebook-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="facebook-en"></div>
<button class="btn-copy" onclick="copyText('facebook-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-linkedin">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="linkedin-de"></div>
<button class="btn-copy" onclick="copyText('linkedin-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="linkedin-en"></div>
<button class="btn-copy" onclick="copyText('linkedin-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-tiktok">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="tiktok-de"></div>
<button class="btn-copy" onclick="copyText('tiktok-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="tiktok-en"></div>
<button class="btn-copy" onclick="copyText('tiktok-en')">&#128203;</button>
</div>
</div>
</div>
</div>
<!-- Email Marketing -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#9993; E-Mail Marketing" data-en="&#9993; Email Marketing"></h3>
<button class="btn-copy-all" onclick="copyAll('email')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="email-de"></div>
<button class="btn-copy" onclick="copyText('email-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="email-en"></div>
<button class="btn-copy" onclick="copyText('email-en')">&#128203;</button>
</div>
</div>
</div>
<!-- SEO Texts -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128270; SEO-Texte & Metadaten" data-en="&#128270; SEO Texts & Metadata"></h3>
<button class="btn-copy-all" onclick="copyAll('seo')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="seo-de"></div>
<button class="btn-copy" onclick="copyText('seo-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="seo-en"></div>
<button class="btn-copy" onclick="copyText('seo-en')">&#128203;</button>
</div>
</div>
</div>
<!-- Press Release -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128240; Pressemitteilung" data-en="&#128240; Press Release"></h3>
<button class="btn-copy-all" onclick="copyAll('press')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="press-de"></div>
<button class="btn-copy" onclick="copyText('press-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="press-en"></div>
<button class="btn-copy" onclick="copyText('press-en')">&#128203;</button>
</div>
</div>
</div>
<!-- Slogan Generator -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128161; Slogans & Taglines" data-en="&#128161; Slogans & Taglines"></h3>
</div>
<div class="slogan-grid" id="sloganGrid"></div>
</div>
<!-- Hashtag Cloud -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="# Hashtag-Wolke" data-en="# Hashtag Cloud"></h3>
<button class="btn-copy-all" onclick="copyHashtags()" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="hashtag-cloud" id="hashtagCloud"></div>
</div>
<!-- Landing Page Preview -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#127760; Landing Page HTML" data-en="&#127760; Landing Page HTML"></h3>
<button class="btn-copy-all" onclick="copyText('landing-code')" data-de="HTML kopieren" data-en="Copy HTML"></button>
</div>
<div class="landing-preview-container">
<div class="preview-toggle">
<button class="preview-btn active" onclick="togglePreview('preview')" data-de="Vorschau" data-en="Preview"></button>
<button class="preview-btn" onclick="togglePreview('code')" data-de="HTML-Code" data-en="HTML Code"></button>
</div>
<div id="landing-preview" class="landing-preview"></div>
<pre id="landing-code" class="landing-code hidden"></pre>
</div>
</div>
<!-- Export Section -->
<div class="card result-card export-card">
<div class="result-header">
<h3 data-de="&#128229; Alles exportieren" data-en="&#128229; Export Everything"></h3>
</div>
<div class="export-grid">
<button class="btn-export" onclick="exportAs('txt')">
<span class="export-icon">&#128196;</span>
<span>TXT</span>
</button>
<button class="btn-export" onclick="exportAs('html')">
<span class="export-icon">&#127760;</span>
<span>HTML</span>
</button>
<button class="btn-export" onclick="exportAs('json')">
<span class="export-icon">&#128218;</span>
<span>JSON</span>
</button>
<button class="btn-export" onclick="exportAs('csv')">
<span class="export-icon">&#128202;</span>
<span>CSV</span>
</button>
</div>
</div>
</section>
</main>
<!-- Toast Notification -->
<div class="toast hidden" id="toast"></div>
<!-- Footer -->
<footer class="footer">
<p data-de="PromoMaster - Vollautomatisches Produkt-Promotion-Tool" data-en="PromoMaster - Fully Automatic Product Promotion Tool"></p>
</footer>
<script src="promo.js"></script>
</body>
</html>
+813
View File
@@ -0,0 +1,813 @@
// === PromoMaster - Product Promotion Tool ===
// Vollautomatisches Produkt-Promotion-Tool in Deutsch und Englisch
(function () {
'use strict';
// --- State ---
let currentLang = 'de';
let selectedStyle = 'professional';
let generatedData = null;
// --- Language System ---
function setLanguage(lang) {
currentLang = lang;
document.documentElement.setAttribute('data-lang', lang);
document.getElementById('btn-de').classList.toggle('active', lang === 'de');
document.getElementById('btn-en').classList.toggle('active', lang === 'en');
document.querySelectorAll('[data-de]').forEach(function (el) {
el.textContent = el.getAttribute('data-' + lang);
});
document.querySelectorAll('[data-de-placeholder]').forEach(function (el) {
el.placeholder = el.getAttribute('data-' + lang + '-placeholder');
});
document.querySelectorAll('select option').forEach(function (opt) {
var val = opt.getAttribute('data-' + lang);
if (val) opt.textContent = val;
});
}
window.setLanguage = setLanguage;
// --- Style Selection ---
function selectStyle(el) {
document.querySelectorAll('.style-option').forEach(function (s) {
s.classList.remove('selected');
});
el.classList.add('selected');
selectedStyle = el.getAttribute('data-style');
}
window.selectStyle = selectStyle;
// --- Character Counter ---
var descInput = document.getElementById('productDescription');
var charCount = document.getElementById('charCount');
if (descInput && charCount) {
descInput.addEventListener('input', function () {
charCount.textContent = descInput.value.length;
});
}
// --- Text Templates ---
var templates = {
professional: {
twitter: {
de: function (p) {
return 'Entdecken Sie ' + p.name + ' \u2013 ' + p.desc + (p.features.length ? '\n\n\u2705 ' + p.features.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtags.slice(0, 4).join(' ');
},
en: function (p) {
return 'Discover ' + p.name + ' \u2013 ' + p.descEn + (p.features.length ? '\n\n\u2705 ' + p.featuresEn.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' ');
}
},
instagram: {
de: function (p) {
return '\u2728 ' + p.name + ' \u2013 Die Zukunft beginnt jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was ' + p.name + ' besonders macht:\n' + p.features.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfekt f\u00fcr: ' + p.audience + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Jetzt f\u00fcr nur ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio \u2b06\ufe0f\n\n' : '') + p.hashtags.join(' ');
},
en: function (p) {
return '\u2728 ' + p.name + ' \u2013 The future starts now!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes ' + p.name + ' special:\n' + p.featuresEn.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfect for: ' + p.audienceEn + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Now only ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio \u2b06\ufe0f\n\n' : '') + p.hashtagsEn.join(' ');
}
},
facebook: {
de: function (p) {
return '\ud83d\ude80 Neu: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udc47 Das sind die Highlights:\n\n' + p.features.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfekt f\u00fcr alle, die ' + p.audience + ' sind.\n\n' : '') + (p.price ? '\ud83d\udcb5 Preis: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Mehr erfahren: ' + p.url + '\n\n' : '') + 'Was denkt ihr? Lasst es uns in den Kommentaren wissen! \ud83d\udc47\n\n' + p.hashtags.slice(0, 5).join(' ');
},
en: function (p) {
return '\ud83d\ude80 New: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udc47 Here are the highlights:\n\n' + p.featuresEn.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfect for everyone who is ' + p.audienceEn + '.\n\n' : '') + (p.price ? '\ud83d\udcb5 Price: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Learn more: ' + p.url + '\n\n' : '') + 'What do you think? Let us know in the comments! \ud83d\udc47\n\n' + p.hashtagsEn.slice(0, 5).join(' ');
}
},
linkedin: {
de: function (p) {
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation trifft Effizienz\n\nIch freue mich, Ihnen ' + p.name + ' vorzustellen.\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die wichtigsten Vorteile:\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Zielgruppe: ' + p.audience + '\n\n' : '') + (p.price ? 'Investition: ' + p.price + '\n\n' : '') + (p.url ? 'Erfahren Sie mehr: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtags.slice(0, 3).join(' ');
},
en: function (p) {
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation meets Efficiency\n\nI\'m excited to introduce ' + p.name + ' to you.\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key benefits:\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Target audience: ' + p.audienceEn + '\n\n' : '') + (p.price ? 'Investment: ' + p.price + '\n\n' : '') + (p.url ? 'Learn more: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtagsEn.slice(0, 3).join(' ');
}
},
tiktok: {
de: function (p) {
return '\ud83d\udd25 POV: Du entdeckst gerade ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio!\n\n' : '') + p.hashtags.join(' ') + ' #fyp #viral #musthave';
},
en: function (p) {
return '\ud83d\udd25 POV: You just discovered ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio!\n\n' : '') + p.hashtagsEn.join(' ') + ' #fyp #viral #musthave';
}
}
},
casual: {
twitter: {
de: function (p) { return 'Hey Leute! \ud83d\udc4b Kennt ihr schon ' + p.name + '? ' + p.desc + (p.features.length ? '\n\nDas Beste daran:\n' + p.features.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nSchaut mal vorbei: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return 'Hey everyone! \ud83d\udc4b Have you heard of ' + p.name + '? ' + p.descEn + (p.features.length ? '\n\nBest thing about it:\n' + p.featuresEn.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nCheck it out: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return 'Schaut mal was ich gefunden habe! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + (p.features.length ? 'Warum ich es liebe:\n' + p.features.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Wer will es auch haben? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtags.join(' '); },
en: function (p) { return 'Look what I found! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + (p.features.length ? 'Why I love it:\n' + p.featuresEn.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Who else wants this? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return 'Hey Freunde! \ud83d\udc4b\n\nIch muss euch unbedingt von ' + p.name + ' erz\u00e4hlen!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was mich \u00fcberzeugt hat:\n' + p.features.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das f\u00fcr ' + p.price + ' \u2013 richtig fair!\n\n' : '') + (p.url ? 'Hier gehts lang: ' + p.url + '\n\n' : '') + 'Kennt ihr das schon? \ud83d\ude0d'; },
en: function (p) { return 'Hey friends! \ud83d\udc4b\n\nI have to tell you about ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What convinced me:\n' + p.featuresEn.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'And all that for ' + p.price + ' \u2013 such a great deal!\n\n' : '') + (p.url ? 'Check it here: ' + p.url + '\n\n' : '') + 'Have you heard of this? \ud83d\ude0d'; }
},
linkedin: {
de: function (p) { return 'Moin zusammen! \ud83d\ude4c\n\nDarf ich vorstellen: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Das macht es so cool:\n' + p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wer hat Lust, das mal auszuprobieren?\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return 'Hey everyone! \ud83d\ude4c\n\nLet me introduce: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes it awesome:\n' + p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Who wants to give it a try?\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return 'OK das m\u00fcsst ihr sehen!! \ud83d\ude31\n\n' + p.name + ' ist einfach WILD!\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Kommentiert wenn ihrs auch braucht! \ud83d\udc47\n\n' + p.hashtags.join(' ') + ' #fyp #musthave'; },
en: function (p) { return 'OK you NEED to see this!! \ud83d\ude31\n\n' + p.name + ' is absolutely WILD!\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Comment if you need this too! \ud83d\udc47\n\n' + p.hashtagsEn.join(' ') + ' #fyp #musthave'; }
}
},
urgent: {
twitter: {
de: function (p) { return '\u26a0\ufe0f NUR F\u00dcR KURZE ZEIT: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 Jetzt zuschlagen: ' + p.price + '\n' : '') + '\u23f0 Begrenztes Angebot \u2013 nicht verpassen!\n\n' + (p.url ? '\ud83d\udc49 Sofort sichern: ' + p.url + '\n\n' : '') + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return '\u26a0\ufe0f LIMITED TIME ONLY: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 Get it now: ' + p.price + '\n' : '') + '\u23f0 Limited offer \u2013 don\'t miss out!\n\n' + (p.url ? '\ud83d\udc49 Grab yours: ' + p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return '\ud83d\udea8 ACHTUNG! \ud83d\udea8\n\n' + p.name + ' ist DA!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.features.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Nur solange der Vorrat reicht!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'JETZT HANDELN bevor es zu sp\u00e4t ist! \ud83d\udc47\n\n' + p.hashtags.join(' '); },
en: function (p) { return '\ud83d\udea8 ATTENTION! \ud83d\udea8\n\n' + p.name + ' is HERE!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.featuresEn.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Only while supplies last!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'ACT NOW before it\'s too late! \ud83d\udc47\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 EILMELDUNG \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' ist endlich verf\u00fcgbar!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Fakten:\n' + p.features.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 ACHTUNG: Angebot endet bald!\n' + (p.price ? '\ud83d\udcb5 Nur ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 SOFORT ZUSCHLAGEN: ' + p.url : ''); },
en: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 BREAKING \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' is finally available!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The facts:\n' + p.featuresEn.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 WARNING: Offer ends soon!\n' + (p.price ? '\ud83d\udcb5 Only ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 GRAB IT NOW: ' + p.url : ''); }
},
linkedin: {
de: function (p) { return '\ud83d\udea8 Dringende Marktchance: ' + p.name + '\n\n' + p.desc + '\n\n' + (p.features.length ? 'Schl\u00fcsselvorteile:\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'Dieses Angebot ist zeitlich begrenzt. Wer jetzt nicht handelt, verpasst eine einmalige Gelegenheit.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return '\ud83d\udea8 Urgent Market Opportunity: ' + p.name + '\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key advantages:\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'This offer is time-limited. Those who don\'t act now will miss a unique opportunity.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + '\u23f0 Letzte Chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEVOR ES WEG IST!\n\n' + p.hashtags.join(' ') + ' #fyp #limitedoffer'; },
en: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + '\u23f0 Last chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEFORE IT\'S GONE!\n\n' + p.hashtagsEn.join(' ') + ' #fyp #limitedoffer'; }
}
},
luxury: {
twitter: {
de: function (p) { return '\u2728 ' + p.name + '\n\nExklusivit\u00e4t neu definiert.\n' + p.desc + '\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' ') + ' #Luxus #Premium'; },
en: function (p) { return '\u2728 ' + p.name + '\n\nRedefining exclusivity.\n' + p.descEn + '\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' ') + ' #Luxury #Premium'; }
},
instagram: {
de: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.desc + '\n\n' + (p.features.length ? 'Exklusive Merkmale:\n' + p.features.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'F\u00fcr alle, die das Beste verdienen.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #Luxury #Exclusive'; },
en: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Exclusive features:\n' + p.featuresEn.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'For those who deserve the finest.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #Luxury #Exclusive'; }
},
facebook: {
de: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfektion kennt keine Kompromisse.\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\nEntdecken Sie mehr: ' + p.url : ''); },
en: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfection knows no compromise.\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\nDiscover more: ' + p.url : ''); }
},
linkedin: {
de: function (p) { return p.name + ' \u2013 Exzellenz in jeder Hinsicht\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wir setzen Ma\u00dfst\u00e4be f\u00fcr Premium-Qualit\u00e4t.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtags.slice(0, 2).join(' '); },
en: function (p) { return p.name + ' \u2013 Excellence in every way\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'We set the standard for premium quality.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtagsEn.slice(0, 2).join(' '); }
},
tiktok: {
de: function (p) { return '\u2728 ' + p.name + ' \u2013 Luxus der n\u00e4chsten Generation\n\n' + p.desc + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #luxury #aesthetic #premium'; },
en: function (p) { return '\u2728 ' + p.name + ' \u2013 Next generation luxury\n\n' + p.descEn + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #luxury #aesthetic #premium'; }
}
},
fun: {
twitter: {
de: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' ist da und es ist der HAMMER! \ud83d\udd28\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Ab gehts: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' is here and it\'s AMAZING! \ud83d\udd28\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Let\'s go: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' hat mein Leben ver\u00e4ndert und ich bin NICHT dramatisch! \ud83d\ude02\n\n' + p.desc + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.features.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Wer ist dabei?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtags.join(' '); },
en: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' changed my life and I\'m NOT being dramatic! \ud83d\ude02\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.featuresEn.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 ES IST SOWEIT! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' ist gelandet und wir k\u00f6nnen nicht aufh\u00f6ren dar\u00fcber zu reden!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\ude0d Das ist alles drin:\n' + p.features.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das Beste? Nur ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAGGT jemanden der das braucht! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; },
en: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 IT\'S HERE! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' has landed and we can\'t stop talking about it!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\ude0d Here\'s what you get:\n' + p.featuresEn.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Best part? Only ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAG someone who needs this! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; }
},
linkedin: {
de: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' existiert jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Highlights (ja, es wird noch besser):\n' + p.features.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Wer will mitmachen? Schreibt mir! \ud83d\ude0e\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' now exists!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The highlights (yes, it gets even better):\n' + p.featuresEn.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in? DM me! \ud83d\ude0e\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return '\ud83e\udee3 Wenn du ' + p.name + ' noch nicht kennst, lebst du unter einem Stein!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 SCHNAPPER!\n' : '') + '\nSpeichern & Teilen nicht vergessen! \ud83d\ude4f\n\n' + p.hashtags.join(' ') + ' #fyp #gamechanger'; },
en: function (p) { return '\ud83e\udee3 If you don\'t know ' + p.name + ' yet, you\'re living under a rock!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 STEAL!\n' : '') + '\nSave & Share! \ud83d\ude4f\n\n' + p.hashtagsEn.join(' ') + ' #fyp #gamechanger'; }
}
},
minimal: {
twitter: {
de: function (p) { return p.name + '.\n' + p.desc + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return p.name + '.\n' + p.descEn + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
instagram: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' / ') : '') + '\n\n' + p.hashtags.join(' '); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' / ') : '') + '\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); }
},
linkedin: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); }
},
tiktok: {
de: function (p) { return p.name + '\n' + p.desc + '\n\n' + p.hashtags.slice(0, 5).join(' ') + ' #minimal'; },
en: function (p) { return p.name + '\n' + p.descEn + '\n\n' + p.hashtagsEn.slice(0, 5).join(' ') + ' #minimal'; }
}
}
};
// --- Email Templates ---
var emailTemplates = {
de: function (p) {
return 'Betreff: Entdecken Sie ' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '...\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Sehr geehrte Damen und Herren,\n\n' +
'wir freuen uns, Ihnen ' + p.name + ' vorzustellen \u2013 ' + p.desc + '\n\n' +
(p.features.length ?
'Die wichtigsten Vorteile auf einen Blick:\n\n' +
p.features.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? 'Ideal f\u00fcr: ' + p.audience + '\n\n' : '') +
(p.price ? 'Unser Angebot: ' + p.price + '\n\n' : '') +
(p.url ? '\u27a1 Jetzt mehr erfahren: ' + p.url + '\n\n' : '') +
'Haben Sie Fragen? Antworten Sie einfach auf diese E-Mail \u2013 wir helfen Ihnen gerne weiter.\n\n' +
'Mit freundlichen Gr\u00fc\u00dfen,\n' +
'Ihr ' + p.name + ' Team\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
'Sie erhalten diese E-Mail, weil Sie sich f\u00fcr ' + p.name + ' interessieren.\n' +
'Abmelden | Datenschutz | Impressum';
},
en: function (p) {
return 'Subject: Discover ' + p.name + ' \u2013 ' + p.descEn.substring(0, 60) + '...\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Dear Customer,\n\n' +
'We are excited to introduce ' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
(p.features.length ?
'Key benefits at a glance:\n\n' +
p.featuresEn.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? 'Ideal for: ' + p.audienceEn + '\n\n' : '') +
(p.price ? 'Our offer: ' + p.price + '\n\n' : '') +
(p.url ? '\u27a1 Learn more: ' + p.url + '\n\n' : '') +
'Have questions? Simply reply to this email \u2013 we\'re happy to help.\n\n' +
'Best regards,\n' +
'The ' + p.name + ' Team\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
'You received this email because you showed interest in ' + p.name + '.\n' +
'Unsubscribe | Privacy Policy | Legal';
}
};
// --- SEO Templates ---
var seoTemplates = {
de: function (p) {
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.desc.substring(0, 50) + ' | Jetzt entdecken\n\n' +
'META DESCRIPTION:\n' + p.desc + (p.features.length ? ' \u2714 ' + p.features.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' Ab ' + p.price + '.' : '') + ' Jetzt informieren!\n\n' +
'SEO KEYWORDS:\n' + p.name + ', ' + p.name.toLowerCase() + ' kaufen, ' + p.name.toLowerCase() + ' test, ' + p.name.toLowerCase() + ' erfahrungen, ' +
(p.category ? p.categoryDe + ', ' : '') + 'beste ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' vergleich, ' + p.name.toLowerCase() + ' angebot\n\n' +
'H1 \u00dcBERSCHRIFT:\n' + p.name + ' \u2013 ' + p.desc + '\n\n' +
'H2 \u00dcBERSCHRIFTEN:\n' +
'Warum ' + p.name + '?\n' +
'Funktionen & Vorteile\n' +
'F\u00fcr wen ist ' + p.name + ' geeignet?\n' +
'Jetzt ' + p.name + ' bestellen\n\n' +
'ALT-TEXT F\u00dcR BILDER:\n' +
p.name + ' Produktbild \u2013 ' + p.desc.substring(0, 60);
},
en: function (p) {
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.descEn.substring(0, 50) + ' | Discover Now\n\n' +
'META DESCRIPTION:\n' + p.descEn + (p.features.length ? ' \u2714 ' + p.featuresEn.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' From ' + p.price + '.' : '') + ' Learn more now!\n\n' +
'SEO KEYWORDS:\n' + p.name + ', buy ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' review, ' + p.name.toLowerCase() + ' features, ' +
(p.category ? p.categoryEn + ', ' : '') + 'best ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' comparison, ' + p.name.toLowerCase() + ' deal\n\n' +
'H1 HEADING:\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
'H2 HEADINGS:\n' +
'Why ' + p.name + '?\n' +
'Features & Benefits\n' +
'Who is ' + p.name + ' for?\n' +
'Order ' + p.name + ' Now\n\n' +
'IMAGE ALT TEXT:\n' +
p.name + ' product image \u2013 ' + p.descEn.substring(0, 60);
}
};
// --- Press Release Templates ---
var pressTemplates = {
de: function (p) {
var today = new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' });
return 'PRESSEMITTEILUNG\n' +
'Datum: ' + today + '\n' +
'Zur sofortigen Ver\u00f6ffentlichung\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
p.name + ': ' + p.desc + '\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Wir freuen uns, die Verf\u00fcgbarkeit von ' + p.name + ' bekannt zu geben. ' + p.desc + '\n\n' +
(p.features.length ?
'Hauptmerkmale von ' + p.name + ':\n\n' +
p.features.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? '"' + p.name + ' wurde speziell f\u00fcr ' + p.audience + ' entwickelt", erkl\u00e4rt das Entwicklerteam.\n\n' : '') +
(p.price ? 'Verf\u00fcgbarkeit & Preis:\n' + p.name + ' ist ab sofort zum Preis von ' + p.price + ' erh\u00e4ltlich.\n\n' : '') +
(p.url ? 'Weitere Informationen finden Sie unter: ' + p.url + '\n\n' : '') +
'Pressekontakt:\n' +
'E-Mail: presse@' + p.name.toLowerCase().replace(/\s+/g, '') + '.de\n' +
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.de') + '\n\n' +
'###';
},
en: function (p) {
var today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
return 'PRESS RELEASE\n' +
'Date: ' + today + '\n' +
'For Immediate Release\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
p.name + ': ' + p.descEn + '\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'We are pleased to announce the availability of ' + p.name + '. ' + p.descEn + '\n\n' +
(p.features.length ?
'Key Features of ' + p.name + ':\n\n' +
p.featuresEn.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? '"' + p.name + ' was specifically designed for ' + p.audienceEn + '," says the development team.\n\n' : '') +
(p.price ? 'Availability & Pricing:\n' + p.name + ' is available now at ' + p.price + '.\n\n' : '') +
(p.url ? 'For more information, visit: ' + p.url + '\n\n' : '') +
'Press Contact:\n' +
'Email: press@' + p.name.toLowerCase().replace(/\s+/g, '') + '.com\n' +
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.com') + '\n\n' +
'###';
}
};
// --- Category Mapping ---
var categoryNames = {
tech: { de: 'Technologie', en: 'Technology' },
fashion: { de: 'Mode & Bekleidung', en: 'Fashion & Apparel' },
food: { de: 'Lebensmittel & Getr\u00e4nke', en: 'Food & Beverages' },
health: { de: 'Gesundheit & Wellness', en: 'Health & Wellness' },
home: { de: 'Haus & Garten', en: 'Home & Garden' },
sport: { de: 'Sport & Fitness', en: 'Sports & Fitness' },
beauty: { de: 'Sch\u00f6nheit & Pflege', en: 'Beauty & Care' },
education: { de: 'Bildung & Kurse', en: 'Education & Courses' },
software: { de: 'Software & Apps', en: 'Software & Apps' },
other: { de: 'Sonstiges', en: 'Other' }
};
// --- Gather Product Data ---
function getProductData() {
var name = document.getElementById('productName').value.trim();
var desc = document.getElementById('productDescription').value.trim();
var price = document.getElementById('productPrice').value.trim();
var url = document.getElementById('productUrl').value.trim();
var features = document.getElementById('productFeatures').value.trim();
var audience = document.getElementById('targetAudience').value.trim();
var category = document.getElementById('productCategory').value;
if (!name || !desc) {
showToast(currentLang === 'de' ? 'Bitte Produktname und Beschreibung eingeben!' : 'Please enter product name and description!');
return null;
}
var featureList = features ? features.split(',').map(function (f) { return f.trim(); }).filter(Boolean) : [];
var catInfo = categoryNames[category] || { de: '', en: '' };
return {
name: name,
desc: desc,
descEn: desc, // User provides in their language; used as-is
price: price,
url: url,
features: featureList,
featuresEn: featureList,
audience: audience,
audienceEn: audience,
category: category,
categoryDe: catInfo.de,
categoryEn: catInfo.en,
hashtags: generateHashtags(name, featureList, category, 'de'),
hashtagsEn: generateHashtags(name, featureList, category, 'en')
};
}
// --- Generate Hashtags ---
function generateHashtags(name, features, category, lang) {
var tags = [];
tags.push('#' + name.replace(/\s+/g, ''));
var catTags = {
tech: { de: ['#Technologie', '#Innovation', '#TechNews', '#Digital', '#Gadget'], en: ['#Technology', '#Innovation', '#TechNews', '#Digital', '#Gadget'] },
fashion: { de: ['#Mode', '#Fashion', '#Style', '#OOTD', '#Trend'], en: ['#Fashion', '#Style', '#OOTD', '#Trend', '#Outfit'] },
food: { de: ['#Foodie', '#Lecker', '#Essen', '#Kochen', '#Genuss'], en: ['#Foodie', '#Delicious', '#FoodLover', '#Cooking', '#Yummy'] },
health: { de: ['#Gesundheit', '#Wellness', '#Fitness', '#Wohlbefinden'], en: ['#Health', '#Wellness', '#Fitness', '#Wellbeing'] },
home: { de: ['#Zuhause', '#Wohnen', '#Interior', '#HomeDecor'], en: ['#Home', '#Living', '#Interior', '#HomeDecor'] },
sport: { de: ['#Sport', '#Fitness', '#Training', '#Motivation'], en: ['#Sports', '#Fitness', '#Training', '#Motivation'] },
beauty: { de: ['#Beauty', '#Pflege', '#Skincare', '#Sch\u00f6nheit'], en: ['#Beauty', '#Skincare', '#SelfCare', '#Glow'] },
education: { de: ['#Bildung', '#Lernen', '#Wissen', '#Weiterbildung'], en: ['#Education', '#Learning', '#Knowledge', '#Growth'] },
software: { de: ['#Software', '#App', '#Digital', '#SaaS', '#Produktivit\u00e4t'], en: ['#Software', '#App', '#Digital', '#SaaS', '#Productivity'] },
other: { de: ['#Neu', '#MustHave', '#Empfehlung'], en: ['#New', '#MustHave', '#Recommended'] }
};
var ct = catTags[category];
if (ct) {
tags = tags.concat(ct[lang] || ct.en);
}
features.slice(0, 2).forEach(function (f) {
tags.push('#' + f.replace(/\s+/g, '').replace(/[^a-zA-Z0-9\u00c0-\u017e]/g, ''));
});
return tags.filter(function (t, i, arr) { return arr.indexOf(t) === i; });
}
// --- Generate Slogans ---
function generateSlogans(p) {
var slogans = [];
var sloganTemplatesDe = [
p.name + ' \u2013 Weil du das Beste verdienst.',
p.name + '. Einfach. Besser. Anders.',
'Die Zukunft hei\u00dft ' + p.name + '.',
p.name + ' \u2013 Dein n\u00e4chster Schritt nach vorn.',
'Erlebe den Unterschied mit ' + p.name + '.',
p.name + '. Mehr als du erwartest.'
];
var sloganTemplatesEn = [
p.name + ' \u2013 Because you deserve the best.',
p.name + '. Simple. Better. Different.',
'The future is called ' + p.name + '.',
p.name + ' \u2013 Your next step forward.',
'Experience the difference with ' + p.name + '.',
p.name + '. More than you expect.'
];
for (var i = 0; i < sloganTemplatesDe.length; i++) {
slogans.push({ de: sloganTemplatesDe[i], en: sloganTemplatesEn[i] });
}
return slogans;
}
// --- Generate Landing Page HTML ---
function generateLandingPage(p) {
var accentColor = '#6C5CE7';
return '<!DOCTYPE html>\n' +
'<html lang="de">\n<head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <title>' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '</title>\n' +
' <meta name="description" content="' + p.desc + '">\n' +
' <meta property="og:title" content="' + p.name + '">\n' +
' <meta property="og:description" content="' + p.desc + '">\n' +
' <style>\n' +
' * { margin: 0; padding: 0; box-sizing: border-box; }\n' +
' body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #333; }\n' +
' .hero { background: linear-gradient(135deg, ' + accentColor + ', #00CEC9); color: #fff; padding: 80px 20px; text-align: center; }\n' +
' .hero h1 { font-size: 3rem; margin-bottom: 16px; }\n' +
' .hero p { font-size: 1.3rem; opacity: 0.9; max-width: 600px; margin: 0 auto 32px; }\n' +
' .cta-btn { display: inline-block; padding: 16px 40px; background: #fff; color: ' + accentColor + '; font-size: 18px; font-weight: 700; border-radius: 50px; text-decoration: none; transition: transform 0.3s; }\n' +
' .cta-btn:hover { transform: scale(1.05); }\n' +
' .features { padding: 60px 20px; max-width: 800px; margin: 0 auto; }\n' +
' .features h2 { text-align: center; font-size: 2rem; margin-bottom: 40px; }\n' +
' .feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 30px; }\n' +
' .feature { text-align: center; padding: 24px; }\n' +
' .feature h3 { color: ' + accentColor + '; margin-bottom: 8px; }\n' +
(p.price ? ' .pricing { text-align: center; padding: 60px 20px; background: #f8f9fa; }\n' +
' .pricing h2 { font-size: 2rem; margin-bottom: 16px; }\n' +
' .price-tag { font-size: 3rem; font-weight: 900; color: ' + accentColor + '; }\n' : '') +
' .footer { text-align: center; padding: 30px; color: #888; font-size: 14px; }\n' +
' </style>\n' +
'</head>\n<body>\n' +
' <section class="hero">\n' +
' <h1>' + p.name + '</h1>\n' +
' <p>' + p.desc + '</p>\n' +
(p.url ? ' <a href="' + escapeHtml(p.url) + '" class="cta-btn">Jetzt entdecken / Discover Now</a>\n' : ' <a href="#features" class="cta-btn">Mehr erfahren / Learn More</a>\n') +
' </section>\n' +
(p.features.length ? ' <section class="features" id="features">\n' +
' <h2>Features</h2>\n' +
' <div class="feature-grid">\n' +
p.features.map(function (f) { return ' <div class="feature">\n <h3>' + escapeHtml(f) + '</h3>\n </div>'; }).join('\n') + '\n' +
' </div>\n' +
' </section>\n' : '') +
(p.price ? ' <section class="pricing">\n' +
' <h2>Preis / Price</h2>\n' +
' <div class="price-tag">' + escapeHtml(p.price) + '</div>\n' +
' </section>\n' : '') +
' <footer class="footer">\n' +
' &copy; ' + new Date().getFullYear() + ' ' + escapeHtml(p.name) + '. All rights reserved.\n' +
' </footer>\n' +
'</body>\n</html>';
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// --- Main Generate Function ---
function generateAll() {
var p = getProductData();
if (!p) return;
var btn = document.getElementById('generateBtn');
btn.classList.add('loading');
setTimeout(function () {
var style = templates[selectedStyle] || templates.professional;
var platforms = ['twitter', 'instagram', 'facebook', 'linkedin', 'tiktok'];
platforms.forEach(function (platform) {
var tpl = style[platform];
if (tpl) {
setText(platform + '-de', tpl.de(p));
setText(platform + '-en', tpl.en(p));
}
});
// Email
setText('email-de', emailTemplates.de(p));
setText('email-en', emailTemplates.en(p));
// SEO
setText('seo-de', seoTemplates.de(p));
setText('seo-en', seoTemplates.en(p));
// Press Release
setText('press-de', pressTemplates.de(p));
setText('press-en', pressTemplates.en(p));
// Slogans
var slogans = generateSlogans(p);
var sloganGrid = document.getElementById('sloganGrid');
sloganGrid.innerHTML = '';
slogans.forEach(function (s) {
var div = document.createElement('div');
div.className = 'slogan-item';
div.innerHTML = '<span class="slogan-lang">DE</span>' + escapeHtml(s.de);
div.onclick = function () { copyToClipboard(s.de); };
sloganGrid.appendChild(div);
var divEn = document.createElement('div');
divEn.className = 'slogan-item';
divEn.innerHTML = '<span class="slogan-lang">EN</span>' + escapeHtml(s.en);
divEn.onclick = function () { copyToClipboard(s.en); };
sloganGrid.appendChild(divEn);
});
// Hashtags
var hashtagCloud = document.getElementById('hashtagCloud');
hashtagCloud.innerHTML = '';
var allTags = p.hashtags.concat(p.hashtagsEn).filter(function (t, i, arr) { return arr.indexOf(t) === i; });
allTags.forEach(function (tag) {
var span = document.createElement('span');
span.className = 'hashtag';
span.textContent = tag;
span.onclick = function () { copyToClipboard(tag); };
hashtagCloud.appendChild(span);
});
// Landing Page
var landingHtml = generateLandingPage(p);
document.getElementById('landing-code').textContent = landingHtml;
var iframe = document.createElement('iframe');
iframe.srcdoc = landingHtml;
var previewDiv = document.getElementById('landing-preview');
previewDiv.innerHTML = '';
previewDiv.appendChild(iframe);
// Store data for export
generatedData = {
product: p,
style: selectedStyle,
social: {},
email: { de: emailTemplates.de(p), en: emailTemplates.en(p) },
seo: { de: seoTemplates.de(p), en: seoTemplates.en(p) },
press: { de: pressTemplates.de(p), en: pressTemplates.en(p) },
slogans: slogans,
hashtags: allTags,
landingPage: landingHtml
};
platforms.forEach(function (platform) {
var tpl = style[platform];
if (tpl) {
generatedData.social[platform] = { de: tpl.de(p), en: tpl.en(p) };
}
});
// Show results
document.getElementById('results').classList.remove('hidden');
document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' });
btn.classList.remove('loading');
showToast(currentLang === 'de' ? 'Alle Werbematerialien wurden generiert!' : 'All promotion materials generated!');
}, 600);
}
window.generateAll = generateAll;
// --- Helper Functions ---
function setText(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text;
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function () {
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
}).catch(function () {
fallbackCopy(text);
});
}
function fallbackCopy(text) {
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
}
function copyText(id) {
var el = document.getElementById(id);
if (el) copyToClipboard(el.textContent);
}
window.copyText = copyText;
function copyAll(section) {
if (!generatedData) return;
var text = '';
if (section === 'social') {
Object.keys(generatedData.social).forEach(function (platform) {
text += '=== ' + platform.toUpperCase() + ' (DE) ===\n' + generatedData.social[platform].de + '\n\n';
text += '=== ' + platform.toUpperCase() + ' (EN) ===\n' + generatedData.social[platform].en + '\n\n';
});
} else if (section === 'email') {
text = '=== EMAIL (DE) ===\n' + generatedData.email.de + '\n\n=== EMAIL (EN) ===\n' + generatedData.email.en;
} else if (section === 'seo') {
text = '=== SEO (DE) ===\n' + generatedData.seo.de + '\n\n=== SEO (EN) ===\n' + generatedData.seo.en;
} else if (section === 'press') {
text = '=== PRESS (DE) ===\n' + generatedData.press.de + '\n\n=== PRESS (EN) ===\n' + generatedData.press.en;
}
copyToClipboard(text);
}
window.copyAll = copyAll;
function copyHashtags() {
if (generatedData) copyToClipboard(generatedData.hashtags.join(' '));
}
window.copyHashtags = copyHashtags;
// --- Tabs ---
function switchTab(btn, tab) {
var parent = btn.closest('.result-card');
parent.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
parent.querySelectorAll('.tab-content').forEach(function (c) { c.classList.add('hidden'); });
document.getElementById('tab-' + tab).classList.remove('hidden');
}
window.switchTab = switchTab;
// --- Landing Page Preview Toggle ---
function togglePreview(mode) {
var preview = document.getElementById('landing-preview');
var code = document.getElementById('landing-code');
var buttons = document.querySelectorAll('.preview-btn');
buttons.forEach(function (b) { b.classList.remove('active'); });
if (mode === 'preview') {
preview.classList.remove('hidden');
code.classList.add('hidden');
buttons[0].classList.add('active');
} else {
preview.classList.add('hidden');
code.classList.remove('hidden');
buttons[1].classList.add('active');
}
}
window.togglePreview = togglePreview;
// --- Export Functions ---
function exportAs(format) {
if (!generatedData) {
showToast(currentLang === 'de' ? 'Bitte zuerst generieren!' : 'Please generate first!');
return;
}
var content = '';
var filename = 'promo-' + generatedData.product.name.replace(/\s+/g, '-').toLowerCase();
var mimeType = 'text/plain';
if (format === 'txt') {
content = buildTextExport();
filename += '.txt';
} else if (format === 'html') {
content = generatedData.landingPage;
filename += '-landingpage.html';
mimeType = 'text/html';
} else if (format === 'json') {
content = JSON.stringify(generatedData, null, 2);
filename += '.json';
mimeType = 'application/json';
} else if (format === 'csv') {
content = buildCsvExport();
filename += '.csv';
mimeType = 'text/csv';
}
downloadFile(content, filename, mimeType);
showToast((currentLang === 'de' ? 'Export als ' : 'Exported as ') + format.toUpperCase() + '!');
}
window.exportAs = exportAs;
function buildTextExport() {
var d = generatedData;
var lines = [];
lines.push('========================================');
lines.push('PROMOMASTER - WERBEMATERIALIEN / PROMOTION MATERIALS');
lines.push('Produkt / Product: ' + d.product.name);
lines.push('Erstellt am / Generated: ' + new Date().toLocaleString());
lines.push('========================================\n');
Object.keys(d.social).forEach(function (platform) {
lines.push('\n--- ' + platform.toUpperCase() + ' (DE) ---');
lines.push(d.social[platform].de);
lines.push('\n--- ' + platform.toUpperCase() + ' (EN) ---');
lines.push(d.social[platform].en);
});
lines.push('\n\n--- E-MAIL MARKETING (DE) ---');
lines.push(d.email.de);
lines.push('\n--- E-MAIL MARKETING (EN) ---');
lines.push(d.email.en);
lines.push('\n\n--- SEO (DE) ---');
lines.push(d.seo.de);
lines.push('\n--- SEO (EN) ---');
lines.push(d.seo.en);
lines.push('\n\n--- PRESSEMITTEILUNG / PRESS RELEASE (DE) ---');
lines.push(d.press.de);
lines.push('\n--- PRESS RELEASE (EN) ---');
lines.push(d.press.en);
lines.push('\n\n--- SLOGANS ---');
d.slogans.forEach(function (s) {
lines.push('DE: ' + s.de);
lines.push('EN: ' + s.en);
});
lines.push('\n\n--- HASHTAGS ---');
lines.push(d.hashtags.join(' '));
return lines.join('\n');
}
function buildCsvExport() {
var d = generatedData;
var rows = [['Platform', 'Language', 'Content']];
Object.keys(d.social).forEach(function (platform) {
rows.push([platform, 'DE', '"' + d.social[platform].de.replace(/"/g, '""') + '"']);
rows.push([platform, 'EN', '"' + d.social[platform].en.replace(/"/g, '""') + '"']);
});
rows.push(['email', 'DE', '"' + d.email.de.replace(/"/g, '""') + '"']);
rows.push(['email', 'EN', '"' + d.email.en.replace(/"/g, '""') + '"']);
rows.push(['seo', 'DE', '"' + d.seo.de.replace(/"/g, '""') + '"']);
rows.push(['seo', 'EN', '"' + d.seo.en.replace(/"/g, '""') + '"']);
rows.push(['press', 'DE', '"' + d.press.de.replace(/"/g, '""') + '"']);
rows.push(['press', 'EN', '"' + d.press.en.replace(/"/g, '""') + '"']);
d.slogans.forEach(function (s, i) {
rows.push(['slogan_' + (i + 1), 'DE', '"' + s.de.replace(/"/g, '""') + '"']);
rows.push(['slogan_' + (i + 1), 'EN', '"' + s.en.replace(/"/g, '""') + '"']);
});
return rows.map(function (r) { return r.join(','); }).join('\n');
}
function downloadFile(content, filename, mimeType) {
var blob = new Blob([content], { type: mimeType + ';charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// --- Toast ---
function showToast(msg) {
var toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.remove('hidden');
toast.classList.add('show');
setTimeout(function () {
toast.classList.remove('show');
setTimeout(function () { toast.classList.add('hidden'); }, 400);
}, 2500);
}
// --- Initialize ---
setLanguage('de');
})();
+724
View File
@@ -0,0 +1,724 @@
/* === PromoMaster - Product Promotion Tool === */
:root {
--primary: #6C5CE7;
--primary-dark: #5A4BD1;
--primary-light: #A29BFE;
--accent: #00CEC9;
--accent-dark: #00B5B0;
--bg: #0F0F1A;
--bg-card: #1A1A2E;
--bg-card-hover: #222240;
--text: #EAEAEA;
--text-muted: #8B8BA3;
--border: #2D2D4A;
--success: #00E676;
--warning: #FFD93D;
--danger: #FF6B6B;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.4);
--glow: 0 0 30px rgba(108, 92, 231, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 20px;
}
/* === Language Toggle === */
.lang-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 100;
display: flex;
gap: 4px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 30px;
padding: 4px;
box-shadow: var(--shadow);
}
.lang-btn {
padding: 8px 16px;
border: none;
border-radius: 26px;
background: transparent;
color: var(--text-muted);
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.lang-btn.active {
background: var(--primary);
color: #fff;
}
.lang-btn:hover:not(.active) {
color: var(--text);
background: var(--border);
}
/* === Hero === */
.hero {
position: relative;
padding: 80px 0 50px;
text-align: center;
overflow: hidden;
}
.hero-bg {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(ellipse at 50% 50%, rgba(108, 92, 231, 0.15) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(0, 206, 201, 0.1) 0%, transparent 40%);
animation: heroPulse 8s ease-in-out infinite;
}
@keyframes heroPulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
}
.hero-title {
font-size: clamp(2.5rem, 6vw, 4rem);
font-weight: 900;
letter-spacing: -2px;
position: relative;
background: linear-gradient(135deg, var(--primary-light), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-icon {
font-size: 0.8em;
-webkit-text-fill-color: initial;
}
.hero-subtitle {
font-size: clamp(1rem, 2.5vw, 1.3rem);
color: var(--text-muted);
margin-top: 12px;
position: relative;
font-weight: 300;
}
/* === Cards === */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
margin-bottom: 24px;
box-shadow: var(--shadow);
transition: border-color 0.3s ease;
}
.card:hover {
border-color: var(--primary);
}
/* === Step Header === */
.step-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.step-number {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary), var(--accent));
border-radius: 12px;
font-weight: 800;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
.step-header h2 {
font-size: 1.4rem;
font-weight: 700;
}
/* === Form === */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input,
.form-group textarea,
.form-group select {
padding: 14px 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 15px;
transition: all 0.3s ease;
outline: none;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.2);
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238B8BA3' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 16px center;
padding-right: 40px;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.char-count {
font-size: 12px;
color: var(--text-muted);
text-align: right;
}
/* === Style Options === */
.style-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.style-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 14px;
background: var(--bg);
border: 2px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.style-option:hover {
border-color: var(--primary-light);
background: var(--bg-card-hover);
}
.style-option.selected {
border-color: var(--primary);
background: rgba(108, 92, 231, 0.1);
box-shadow: var(--glow);
}
.style-icon {
font-size: 32px;
}
.style-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.style-option.selected .style-label {
color: var(--text);
}
/* === Generate Button === */
.generate-section {
text-align: center;
margin: 40px 0;
}
.btn-generate {
padding: 18px 48px;
font-size: 18px;
font-weight: 700;
font-family: inherit;
color: #fff;
background: linear-gradient(135deg, var(--primary), var(--accent));
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 30px rgba(108, 92, 231, 0.4);
display: inline-flex;
align-items: center;
gap: 10px;
}
.btn-generate:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(108, 92, 231, 0.5);
}
.btn-generate:active {
transform: translateY(0);
}
.btn-generate.loading {
pointer-events: none;
opacity: 0.8;
}
.btn-generate.loading .btn-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.btn-icon {
font-size: 22px;
display: inline-block;
}
/* === Results === */
.results-section {
animation: fadeInUp 0.6s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.result-card {
position: relative;
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.result-header h3 {
font-size: 1.2rem;
font-weight: 700;
}
.btn-copy-all {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--primary-light);
background: rgba(108, 92, 231, 0.1);
border: 1px solid var(--primary);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-copy-all:hover {
background: var(--primary);
color: #fff;
}
/* === Tabs === */
.result-tabs {
display: flex;
gap: 6px;
margin-bottom: 20px;
overflow-x: auto;
padding-bottom: 4px;
}
.tab-btn {
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--text);
border-color: var(--primary-light);
}
.tab-btn.active {
color: #fff;
background: var(--primary);
border-color: var(--primary);
}
.tab-content {
transition: all 0.3s ease;
}
/* === Language Results === */
.lang-results {
display: flex;
flex-direction: column;
gap: 16px;
}
.lang-result {
position: relative;
padding: 20px;
padding-left: 56px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.lang-badge {
position: absolute;
top: 20px;
left: 16px;
padding: 3px 10px;
font-size: 11px;
font-weight: 800;
color: #fff;
background: linear-gradient(135deg, #E74C3C, #C0392B);
border-radius: 6px;
letter-spacing: 1px;
}
.lang-badge.en {
background: linear-gradient(135deg, #2980B9, #2471A3);
}
.result-text {
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
color: var(--text);
padding-right: 40px;
}
.btn-copy {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
color: var(--text);
}
.btn-copy:hover {
background: var(--primary);
border-color: var(--primary);
}
/* === Slogans === */
.slogan-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.slogan-item {
padding: 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
text-align: center;
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.slogan-item:hover {
border-color: var(--accent);
background: var(--bg-card-hover);
}
.slogan-item .slogan-lang {
font-size: 10px;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 1px;
display: block;
margin-bottom: 6px;
}
/* === Hashtags === */
.hashtag-cloud {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hashtag {
padding: 8px 16px;
background: rgba(108, 92, 231, 0.1);
border: 1px solid var(--primary);
border-radius: 20px;
font-size: 14px;
font-weight: 600;
color: var(--primary-light);
cursor: pointer;
transition: all 0.3s ease;
}
.hashtag:hover {
background: var(--primary);
color: #fff;
}
/* === Landing Page Preview === */
.landing-preview-container {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.preview-toggle {
display: flex;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.preview-btn {
flex: 1;
padding: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.preview-btn.active {
color: var(--text);
background: var(--bg-card);
border-bottom: 2px solid var(--primary);
}
.landing-preview {
min-height: 300px;
background: #fff;
}
.landing-preview iframe {
width: 100%;
height: 500px;
border: none;
}
.landing-code {
padding: 20px;
background: var(--bg);
color: var(--accent);
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
max-height: 400px;
white-space: pre;
margin: 0;
cursor: pointer;
}
/* === Export === */
.export-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.btn-export {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-export:hover {
border-color: var(--accent);
background: var(--bg-card-hover);
transform: translateY(-2px);
}
.export-icon {
font-size: 28px;
}
/* === Toast === */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: 14px 28px;
background: var(--success);
color: #000;
font-weight: 600;
font-size: 14px;
border-radius: 30px;
z-index: 1000;
opacity: 0;
transition: all 0.4s ease;
box-shadow: 0 8px 30px rgba(0, 230, 118, 0.3);
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* === Footer === */
.footer {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 14px;
border-top: 1px solid var(--border);
margin-top: 60px;
}
/* === Hidden === */
.hidden {
display: none !important;
}
/* === Responsive === */
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.style-grid {
grid-template-columns: repeat(2, 1fr);
}
.slogan-grid {
grid-template-columns: 1fr;
}
.export-grid {
grid-template-columns: repeat(2, 1fr);
}
.card {
padding: 24px 18px;
}
.hero {
padding: 60px 0 30px;
}
.lang-toggle {
top: 12px;
right: 12px;
}
.result-tabs {
gap: 4px;
}
.tab-btn {
padding: 8px 14px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.style-grid {
grid-template-columns: 1fr;
}
.export-grid {
grid-template-columns: 1fr 1fr;
}
.btn-generate {
padding: 16px 32px;
font-size: 16px;
}
}
+120
View File
@@ -0,0 +1,120 @@
//
// AppDelegate.swift
// PsytranceVisualizer
//
// Application delegate handling app lifecycle
//
import AppKit
import AVFoundation
/// Application delegate
final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Properties
private var mainWindowController: MainWindowController?
// MARK: - App Lifecycle
func applicationDidFinishLaunching(_ notification: Notification) {
// Request microphone permission
requestMicrophonePermission()
// Create and show main window
mainWindowController = MainWindowController()
mainWindowController?.showWindow(nil)
mainWindowController?.window?.makeKeyAndOrderFront(nil)
// Activate the application
NSApp.activate(ignoringOtherApps: true)
print("[AppDelegate] Application launched")
}
func applicationWillTerminate(_ notification: Notification) {
// Save settings
SettingsManager.shared.saveNow()
print("[AppDelegate] Application terminating")
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
}
// MARK: - Permissions
private func requestMicrophonePermission() {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
print("[AppDelegate] Microphone access already authorized")
case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) { granted in
if granted {
print("[AppDelegate] Microphone access granted")
} else {
print("[AppDelegate] Microphone access denied")
self.showMicrophonePermissionAlert()
}
}
case .denied, .restricted:
print("[AppDelegate] Microphone access denied or restricted")
showMicrophonePermissionAlert()
@unknown default:
break
}
}
private func showMicrophonePermissionAlert() {
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = "Microphone Access Required"
alert.informativeText = "Psytrance Visualizer needs access to your audio input to visualize music. Please enable microphone access in System Preferences > Security & Privacy > Privacy > Microphone."
alert.alertStyle = .warning
alert.addButton(withTitle: "Open System Preferences")
alert.addButton(withTitle: "Cancel")
if alert.runModal() == .alertFirstButtonReturn {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
NSWorkspace.shared.open(url)
}
}
}
}
// MARK: - Menu Actions
@IBAction func showAbout(_ sender: Any) {
let alert = NSAlert()
alert.messageText = "Psytrance Visualizer"
alert.informativeText = """
An audio-reactive visualizer for psytrance music.
8 Visualization Modes:
1 - FFT Classic
2 - Mel Spectrogram
3 - Sub-Bass
4 - Sidechain Pump
5 - Harmonic/Noise
6 - Mandelbrot
7 - Tunnel Warp
8 - DMT Geometry
Keyboard Shortcuts:
1-8: Switch visualization mode
F: Toggle fullscreen
ESC: Exit fullscreen
Tip: Use a virtual audio device like BlackHole to route system audio.
"""
alert.alertStyle = .informational
alert.runModal()
}
}
@@ -0,0 +1,133 @@
//
// PsytranceVisualizerApp.swift
// PsytranceVisualizer
//
// Main application entry point
//
import AppKit
// MARK: - Main Entry Point
/// Application entry point
@main
struct PsytranceVisualizerApp {
static func main() {
// Create the application
let app = NSApplication.shared
// Set up the delegate
let delegate = AppDelegate()
app.delegate = delegate
// Set activation policy
app.setActivationPolicy(.regular)
// Create the main menu
setupMainMenu()
// Run the application
app.run()
}
/// Sets up the application's main menu
private static func setupMainMenu() {
let mainMenu = NSMenu()
// Application menu
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "About Psytrance Visualizer",
action: #selector(AppDelegate.showAbout(_:)),
keyEquivalent: "")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Hide Psytrance Visualizer",
action: #selector(NSApplication.hide(_:)),
keyEquivalent: "h")
let hideOthersItem = appMenu.addItem(withTitle: "Hide Others",
action: #selector(NSApplication.hideOtherApplications(_:)),
keyEquivalent: "h")
hideOthersItem.keyEquivalentModifierMask = [.command, .option]
appMenu.addItem(withTitle: "Show All",
action: #selector(NSApplication.unhideAllApplications(_:)),
keyEquivalent: "")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Quit Psytrance Visualizer",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
// View menu
let viewMenuItem = NSMenuItem()
mainMenu.addItem(viewMenuItem)
let viewMenu = NSMenu(title: "View")
viewMenuItem.submenu = viewMenu
viewMenu.addItem(withTitle: "Toggle Fullscreen",
action: #selector(NSWindow.toggleFullScreen(_:)),
keyEquivalent: "f")
viewMenu.addItem(NSMenuItem.separator())
// Visualization mode submenu
let modesMenuItem = NSMenuItem(title: "Visualization Mode", action: nil, keyEquivalent: "")
let modesMenu = NSMenu()
for mode in VisualizationMode.allCases {
let item = NSMenuItem(title: mode.displayName,
action: nil,
keyEquivalent: mode.shortcut)
item.tag = mode.rawValue
modesMenu.addItem(item)
}
modesMenuItem.submenu = modesMenu
viewMenu.addItem(modesMenuItem)
// Window menu
let windowMenuItem = NSMenuItem()
mainMenu.addItem(windowMenuItem)
let windowMenu = NSMenu(title: "Window")
windowMenuItem.submenu = windowMenu
windowMenu.addItem(withTitle: "Minimize",
action: #selector(NSWindow.miniaturize(_:)),
keyEquivalent: "m")
windowMenu.addItem(withTitle: "Zoom",
action: #selector(NSWindow.zoom(_:)),
keyEquivalent: "")
windowMenu.addItem(NSMenuItem.separator())
windowMenu.addItem(withTitle: "Bring All to Front",
action: #selector(NSApplication.arrangeInFront(_:)),
keyEquivalent: "")
// Help menu
let helpMenuItem = NSMenuItem()
mainMenu.addItem(helpMenuItem)
let helpMenu = NSMenu(title: "Help")
helpMenuItem.submenu = helpMenu
helpMenu.addItem(withTitle: "Psytrance Visualizer Help",
action: #selector(AppDelegate.showAbout(_:)),
keyEquivalent: "?")
NSApp.mainMenu = mainMenu
NSApp.windowsMenu = windowMenu
NSApp.helpMenu = helpMenu
}
}
@@ -0,0 +1,357 @@
//
// AudioInputManager.swift
// PsytranceVisualizer
//
// Manages audio input devices and captures audio buffers
//
import AVFoundation
import CoreAudio
import Combine
/// Represents an audio input device
struct AudioDevice: Identifiable, Hashable {
let id: AudioDeviceID
let uid: String
let name: String
let manufacturer: String
let isInput: Bool
func hash(into hasher: inout Hasher) {
hasher.combine(uid)
}
static func == (lhs: AudioDevice, rhs: AudioDevice) -> Bool {
lhs.uid == rhs.uid
}
}
/// Manages audio input capture using AVAudioEngine
final class AudioInputManager: ObservableObject {
// MARK: - Published Properties
@Published private(set) var availableDevices: [AudioDevice] = []
@Published private(set) var selectedDevice: AudioDevice?
@Published private(set) var isRunning = false
@Published private(set) var currentBufferSize: Int = 1024
// MARK: - Audio Properties
private var audioEngine: AVAudioEngine?
private var inputNode: AVAudioInputNode?
private let sampleRate: Double = 44100.0
// MARK: - Callbacks
var onAudioBuffer: ((AVAudioPCMBuffer) -> Void)?
// MARK: - Private Properties
private var deviceListenerBlock: AudioObjectPropertyListenerBlock?
private let processingQueue = DispatchQueue(label: "com.psytrance.audio", qos: .userInteractive)
// MARK: - Initialization
init() {
refreshDeviceList()
setupDeviceChangeListener()
}
deinit {
stop()
removeDeviceChangeListener()
}
// MARK: - Public Methods
/// Returns list of available audio input devices
func getAvailableInputDevices() -> [AudioDevice] {
return availableDevices
}
/// Refreshes the list of available audio input devices
func refreshDeviceList() {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&dataSize
)
guard status == noErr else {
print("[AudioInputManager] Failed to get device list size: \(status)")
return
}
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&dataSize,
&deviceIDs
)
guard status == noErr else {
print("[AudioInputManager] Failed to get device list: \(status)")
return
}
var devices: [AudioDevice] = []
for deviceID in deviceIDs {
if let device = getDeviceInfo(deviceID: deviceID), device.isInput {
devices.append(device)
}
}
DispatchQueue.main.async {
self.availableDevices = devices
print("[AudioInputManager] Found \(devices.count) input devices")
}
}
/// Selects an audio input device by UID
func selectDevice(uid: String) {
guard let device = availableDevices.first(where: { $0.uid == uid }) else {
print("[AudioInputManager] Device not found: \(uid)")
return
}
let wasRunning = isRunning
if wasRunning {
stop()
}
selectedDevice = device
setSystemInputDevice(deviceID: device.id)
if wasRunning {
start()
}
print("[AudioInputManager] Selected device: \(device.name)")
}
/// Sets the buffer size (512 or 1024)
func setBufferSize(_ size: Int) {
guard [512, 1024].contains(size) else {
print("[AudioInputManager] Invalid buffer size: \(size)")
return
}
let wasRunning = isRunning
if wasRunning {
stop()
}
currentBufferSize = size
if wasRunning {
start()
}
print("[AudioInputManager] Buffer size set to: \(size)")
}
/// Starts audio capture
func start() {
guard !isRunning else { return }
do {
// Create new audio engine
audioEngine = AVAudioEngine()
guard let engine = audioEngine else { return }
inputNode = engine.inputNode
guard let inputNode = inputNode else {
print("[AudioInputManager] No input node available")
return
}
// Get the input format
let inputFormat = inputNode.outputFormat(forBus: 0)
print("[AudioInputManager] Input format: \(inputFormat)")
// Install tap on input node
let bufferSize = AVAudioFrameCount(currentBufferSize)
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] buffer, _ in
self?.processingQueue.async {
self?.onAudioBuffer?(buffer)
}
}
// Prepare and start the engine
engine.prepare()
try engine.start()
DispatchQueue.main.async {
self.isRunning = true
}
print("[AudioInputManager] Audio capture started")
} catch {
print("[AudioInputManager] Failed to start audio capture: \(error)")
}
}
/// Stops audio capture
func stop() {
guard isRunning else { return }
inputNode?.removeTap(onBus: 0)
audioEngine?.stop()
audioEngine = nil
inputNode = nil
DispatchQueue.main.async {
self.isRunning = false
}
print("[AudioInputManager] Audio capture stopped")
}
// MARK: - Private Methods
/// Gets device info for a specific device ID
private func getDeviceInfo(deviceID: AudioDeviceID) -> AudioDevice? {
// Check if device has input channels
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &dataSize)
guard status == noErr, dataSize > 0 else { return nil }
let bufferListPointer = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: Int(dataSize))
defer { bufferListPointer.deallocate() }
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &dataSize, bufferListPointer)
guard status == noErr else { return nil }
let bufferList = UnsafeMutableAudioBufferListPointer(bufferListPointer)
var inputChannelCount: UInt32 = 0
for buffer in bufferList {
inputChannelCount += buffer.mNumberChannels
}
guard inputChannelCount > 0 else { return nil }
// Get device UID
var uid: CFString = "" as CFString
var uidSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceUID
propertyAddress.mScope = kAudioObjectPropertyScopeGlobal
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &uidSize, &uid)
guard status == noErr else { return nil }
// Get device name
var name: CFString = "" as CFString
var nameSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &nameSize, &name)
guard status == noErr else { return nil }
// Get manufacturer
var manufacturer: CFString = "" as CFString
var manufacturerSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceManufacturerCFString
AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &manufacturerSize, &manufacturer)
return AudioDevice(
id: deviceID,
uid: uid as String,
name: name as String,
manufacturer: manufacturer as String,
isInput: true
)
}
/// Sets the system default input device
private func setSystemInputDevice(deviceID: AudioDeviceID) {
var deviceIDCopy = deviceID
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
let status = AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
UInt32(MemoryLayout<AudioDeviceID>.size),
&deviceIDCopy
)
if status != noErr {
print("[AudioInputManager] Failed to set input device: \(status)")
}
}
/// Sets up listener for device changes
private func setupDeviceChangeListener() {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
deviceListenerBlock = { [weak self] _, _ in
DispatchQueue.main.async {
self?.refreshDeviceList()
}
}
if let block = deviceListenerBlock {
AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
DispatchQueue.main,
block
)
}
}
/// Removes device change listener
private func removeDeviceChangeListener() {
guard let block = deviceListenerBlock else { return }
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
DispatchQueue.main,
block
)
}
}
+468
View File
@@ -0,0 +1,468 @@
//
// DSPEngine.swift
// PsytranceVisualizer
//
// Digital Signal Processing engine for audio analysis
//
import Accelerate
import AVFoundation
/// DSP Engine for real-time audio analysis
final class DSPEngine {
// MARK: - Configuration
private let sampleRate: Float = 44100.0
private var fftSize: Int
private let melBandCount: Int = 64
private let subBassUpperFreq: Float = 100.0
private let historySize: Int = 128
// MARK: - FFT Setup
private var fftSetup: vDSP_DFT_Setup?
private var window: [Float]
private var realPart: [Float]
private var imagPart: [Float]
private var magnitudes: [Float]
// MARK: - Mel Filterbank
private var melFilterbank: [[Float]]
private var melOutput: [Float]
// MARK: - Analysis State
private var subBassHistory: [Float]
private var previousMagnitudes: [Float]
private var envelopeValue: Float = 0
private var previousEnvelope: Float = 0
private var pumpHistory: [Float]
private var lastPeakTime: Double = 0
private var peakThreshold: Float = 0.3
// MARK: - Reactivity
private var reactivity: Float = 0.5
private var smoothingFactor: Float = 0.3
// MARK: - Initialization
init(bufferSize: Int = 1024) {
self.fftSize = bufferSize
// Initialize FFT arrays
self.window = [Float](repeating: 0, count: fftSize)
self.realPart = [Float](repeating: 0, count: fftSize)
self.imagPart = [Float](repeating: 0, count: fftSize)
self.magnitudes = [Float](repeating: 0, count: fftSize / 2)
self.previousMagnitudes = [Float](repeating: 0, count: fftSize / 2)
// Initialize Mel arrays
self.melOutput = [Float](repeating: 0, count: melBandCount)
self.melFilterbank = []
// Initialize history arrays
self.subBassHistory = [Float](repeating: 0, count: historySize)
self.pumpHistory = [Float](repeating: 0, count: 64)
// Create Hann window
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
// Create FFT setup
fftSetup = vDSP_DFT_zop_CreateSetup(
nil,
vDSP_Length(fftSize),
.FORWARD
)
// Build Mel filterbank
buildMelFilterbank()
}
deinit {
if let setup = fftSetup {
vDSP_DFT_DestroySetup(setup)
}
}
// MARK: - Public Methods
/// Sets reactivity value (0.0 - 1.0)
func setReactivity(_ value: Float) {
reactivity = max(0.0, min(1.0, value))
// Adjust smoothing based on reactivity (higher reactivity = less smoothing)
smoothingFactor = 0.1 + (1.0 - reactivity) * 0.4
}
/// Reconfigures for new buffer size
func setBufferSize(_ size: Int) {
guard size != fftSize else { return }
fftSize = size
// Reinitialize arrays
window = [Float](repeating: 0, count: fftSize)
realPart = [Float](repeating: 0, count: fftSize)
imagPart = [Float](repeating: 0, count: fftSize)
magnitudes = [Float](repeating: 0, count: fftSize / 2)
previousMagnitudes = [Float](repeating: 0, count: fftSize / 2)
// Recreate window
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
// Recreate FFT setup
if let setup = fftSetup {
vDSP_DFT_DestroySetup(setup)
}
fftSetup = vDSP_DFT_zop_CreateSetup(nil, vDSP_Length(fftSize), .FORWARD)
// Rebuild filterbank
buildMelFilterbank()
}
/// Processes audio buffer and returns analysis data
func process(buffer: AVAudioPCMBuffer) -> AudioAnalysisData {
guard let channelData = buffer.floatChannelData else {
return .empty
}
let frameCount = Int(buffer.frameLength)
let channelCount = Int(buffer.format.channelCount)
// Extract stereo channels
var leftChannel = [Float](repeating: 0, count: frameCount)
var rightChannel = [Float](repeating: 0, count: frameCount)
if channelCount >= 1 {
leftChannel = Array(UnsafeBufferPointer(start: channelData[0], count: frameCount))
}
if channelCount >= 2 {
rightChannel = Array(UnsafeBufferPointer(start: channelData[1], count: frameCount))
} else {
rightChannel = leftChannel
}
// Mix to mono for analysis
var monoBuffer = [Float](repeating: 0, count: frameCount)
vDSP_vadd(leftChannel, 1, rightChannel, 1, &monoBuffer, 1, vDSP_Length(frameCount))
var half: Float = 0.5
vDSP_vsmul(monoBuffer, 1, &half, &monoBuffer, 1, vDSP_Length(frameCount))
// Calculate RMS
var rmsValue: Float = 0
vDSP_rmsqv(monoBuffer, 1, &rmsValue, vDSP_Length(frameCount))
// Perform FFT
let fftMagnitudes = performFFT(monoBuffer)
// Calculate Mel bands
let melBands = calculateMelBands(from: fftMagnitudes)
// Extract sub-bass
let subBassEnergy = calculateSubBassEnergy(from: fftMagnitudes)
// Update sub-bass history
subBassHistory.removeFirst()
subBassHistory.append(subBassEnergy)
// Calculate sidechain envelope and pump detection
let (envelope, pumpAmount, isPumping) = detectSidechainPump(subBassEnergy: subBassEnergy)
// Calculate HNR
let hnrRatio = calculateHNR(buffer: monoBuffer)
// Detect peaks/transients
let (isPeak, peakIntensity) = detectPeak(rms: rmsValue)
// Calculate spectral centroid
let spectralCentroid = calculateSpectralCentroid(magnitudes: fftMagnitudes)
return AudioAnalysisData(
fftMagnitudes: fftMagnitudes,
melBands: melBands,
subBassEnergy: subBassEnergy,
subBassHistory: subBassHistory,
sidechainEnvelope: envelope,
sidechainPumpAmount: pumpAmount,
isPumping: isPumping,
hnrRatio: hnrRatio,
isPeak: isPeak,
peakIntensity: peakIntensity,
leftChannel: leftChannel,
rightChannel: rightChannel,
spectralCentroid: spectralCentroid,
rmsLevel: rmsValue
)
}
// MARK: - FFT
private func performFFT(_ buffer: [Float]) -> [Float] {
guard let setup = fftSetup else { return magnitudes }
let count = min(buffer.count, fftSize)
// Apply window
var windowedBuffer = [Float](repeating: 0, count: fftSize)
for i in 0..<count {
windowedBuffer[i] = buffer[i] * window[i]
}
// Prepare for DFT (separate into real and imaginary)
for i in 0..<fftSize {
realPart[i] = windowedBuffer[i]
imagPart[i] = 0
}
// Perform DFT
var outputReal = [Float](repeating: 0, count: fftSize)
var outputImag = [Float](repeating: 0, count: fftSize)
vDSP_DFT_Execute(setup, realPart, imagPart, &outputReal, &outputImag)
// Calculate magnitudes
let halfSize = fftSize / 2
var newMagnitudes = [Float](repeating: 0, count: halfSize)
for i in 0..<halfSize {
let real = outputReal[i]
let imag = outputImag[i]
newMagnitudes[i] = sqrt(real * real + imag * imag) / Float(fftSize)
}
// Apply smoothing
for i in 0..<halfSize {
magnitudes[i] = magnitudes[i] * smoothingFactor + newMagnitudes[i] * (1.0 - smoothingFactor)
}
previousMagnitudes = magnitudes
return magnitudes
}
// MARK: - Mel Filterbank
private func buildMelFilterbank() {
let halfFFT = fftSize / 2
let nyquist = sampleRate / 2.0
// Mel scale conversion
func hzToMel(_ hz: Float) -> Float {
return 2595.0 * log10(1.0 + hz / 700.0)
}
func melToHz(_ mel: Float) -> Float {
return 700.0 * (pow(10.0, mel / 2595.0) - 1.0)
}
let melMin = hzToMel(20.0)
let melMax = hzToMel(nyquist)
// Create mel points
var melPoints = [Float](repeating: 0, count: melBandCount + 2)
for i in 0..<melBandCount + 2 {
melPoints[i] = melMin + Float(i) * (melMax - melMin) / Float(melBandCount + 1)
}
// Convert back to Hz
var hzPoints = melPoints.map { melToHz($0) }
// Convert to FFT bins
var binPoints = hzPoints.map { Int($0 / nyquist * Float(halfFFT)) }
// Build triangular filters
melFilterbank = []
for m in 1...melBandCount {
var filter = [Float](repeating: 0, count: halfFFT)
let startBin = binPoints[m - 1]
let centerBin = binPoints[m]
let endBin = binPoints[m + 1]
// Rising edge
for k in startBin..<centerBin {
if centerBin != startBin {
filter[k] = Float(k - startBin) / Float(centerBin - startBin)
}
}
// Falling edge
for k in centerBin..<endBin {
if endBin != centerBin {
filter[k] = Float(endBin - k) / Float(endBin - centerBin)
}
}
melFilterbank.append(filter)
}
}
private func calculateMelBands(from magnitudes: [Float]) -> [Float] {
var result = [Float](repeating: 0, count: melBandCount)
for (i, filter) in melFilterbank.enumerated() {
var sum: Float = 0
let count = min(filter.count, magnitudes.count)
for j in 0..<count {
sum += magnitudes[j] * filter[j]
}
// Apply logarithmic scaling
result[i] = log10(1.0 + sum * 10.0) / log10(11.0)
}
// Apply smoothing to mel output
for i in 0..<melBandCount {
melOutput[i] = melOutput[i] * smoothingFactor + result[i] * (1.0 - smoothingFactor)
}
return melOutput
}
// MARK: - Sub-Bass Analysis
private func calculateSubBassEnergy(from magnitudes: [Float]) -> Float {
let binFrequency = sampleRate / Float(fftSize)
let subBassBinCount = Int(subBassUpperFreq / binFrequency)
guard subBassBinCount > 0, magnitudes.count >= subBassBinCount else { return 0 }
var sum: Float = 0
for i in 0..<subBassBinCount {
sum += magnitudes[i] * magnitudes[i]
}
let rms = sqrt(sum / Float(subBassBinCount))
// Normalize and apply gain
let normalized = min(1.0, rms * 5.0 * (1.0 + reactivity))
return normalized
}
// MARK: - Sidechain Pump Detection
private func detectSidechainPump(subBassEnergy: Float) -> (envelope: Float, pumpAmount: Float, isPumping: Bool) {
// Envelope follower with fast attack, slow release
let attackTime: Float = 0.005 // 5ms attack
let releaseTime: Float = 0.15 // 150ms release
let attackCoeff = exp(-1.0 / (sampleRate * attackTime))
let releaseCoeff = exp(-1.0 / (sampleRate * releaseTime))
if subBassEnergy > envelopeValue {
envelopeValue = attackCoeff * envelopeValue + (1.0 - attackCoeff) * subBassEnergy
} else {
envelopeValue = releaseCoeff * envelopeValue + (1.0 - releaseCoeff) * subBassEnergy
}
// Update pump history
pumpHistory.removeFirst()
pumpHistory.append(envelopeValue)
// Analyze pump periodicity
var pumpAmount: Float = 0
var isPumping = false
// Look for characteristic pump pattern (rise and fall)
let derivative = envelopeValue - previousEnvelope
previousEnvelope = envelopeValue
// Detect pump by finding periodic envelope variations
if pumpHistory.count >= 32 {
let recent = Array(pumpHistory.suffix(32))
var variance: Float = 0
let mean = recent.reduce(0, +) / Float(recent.count)
for value in recent {
variance += (value - mean) * (value - mean)
}
variance /= Float(recent.count)
// Higher variance = more pumping
pumpAmount = min(1.0, sqrt(variance) * 4.0)
isPumping = pumpAmount > 0.3 && abs(derivative) > 0.02
}
return (envelopeValue, pumpAmount, isPumping)
}
// MARK: - HNR Calculation
private func calculateHNR(buffer: [Float]) -> Float {
// Use autocorrelation to estimate harmonicity
let frameSize = min(buffer.count, 512)
var autocorr = [Float](repeating: 0, count: frameSize)
// Compute autocorrelation
vDSP_conv(buffer, 1, buffer, 1, &autocorr, 1, vDSP_Length(frameSize), vDSP_Length(frameSize))
// Find the peak in autocorrelation (excluding lag 0)
let minLag = 20 // Minimum lag to avoid DC component
let maxLag = min(frameSize - 1, 400) // Maximum lag
guard maxLag > minLag else { return 0.5 }
var maxValue: Float = 0
var maxIndex: vDSP_Length = 0
let searchRange = Array(autocorr[minLag...maxLag])
vDSP_maxvi(searchRange, 1, &maxValue, &maxIndex, vDSP_Length(searchRange.count))
// Calculate HNR as ratio of peak to first value
let noiseFloor = autocorr.suffix(from: maxLag).reduce(0) { $0 + abs($1) } / Float(frameSize - maxLag)
let harmonicPower = maxValue
let noisePower = max(noiseFloor, 0.0001)
// Convert to 0-1 range
let hnr = harmonicPower / (harmonicPower + noisePower)
return max(0.0, min(1.0, hnr))
}
// MARK: - Peak Detection
private var previousRMS: Float = 0
private var rmsHistory: [Float] = Array(repeating: 0, count: 16)
private func detectPeak(rms: Float) -> (isPeak: Bool, intensity: Float) {
// Update history
rmsHistory.removeFirst()
rmsHistory.append(rms)
// Calculate moving average
let average = rmsHistory.reduce(0, +) / Float(rmsHistory.count)
// Detect sudden increase
let increase = rms - previousRMS
let threshold = average * (0.5 + reactivity * 0.5)
previousRMS = rms
let isPeak = increase > threshold && rms > average * 1.5
let intensity = isPeak ? min(1.0, increase / max(average, 0.01) * 2.0) : 0
return (isPeak, intensity)
}
// MARK: - Spectral Centroid
private func calculateSpectralCentroid(magnitudes: [Float]) -> Float {
var weightedSum: Float = 0
var sum: Float = 0
for (i, mag) in magnitudes.enumerated() {
weightedSum += Float(i) * mag
sum += mag
}
guard sum > 0 else { return 0.5 }
let centroid = weightedSum / sum
let normalized = centroid / Float(magnitudes.count)
return max(0.0, min(1.0, normalized))
}
}
@@ -0,0 +1,90 @@
//
// AppSettings.swift
// PsytranceVisualizer
//
// Persistent application settings
//
import Foundation
/// Application settings that are persisted between sessions
struct AppSettings: Codable {
/// Selected audio input device UID
var selectedAudioDeviceUID: String?
/// Audio buffer size (512 or 1024 samples)
var bufferSize: Int
/// Last used visualization mode (1-8)
var lastVisualizationMode: Int
/// Reactivity slider value (0.0 - 1.0)
var reactivity: Float
/// Whether app was in fullscreen mode
var isFullscreen: Bool
/// Last window frame (for restoration)
var windowFrame: CodableRect?
/// Volume/gain adjustment
var inputGain: Float
/// Whether to show FPS counter
var showFPS: Bool
/// Default settings
static var `default`: AppSettings {
AppSettings(
selectedAudioDeviceUID: nil,
bufferSize: 1024,
lastVisualizationMode: 1,
reactivity: 0.5,
isFullscreen: false,
windowFrame: nil,
inputGain: 1.0,
showFPS: false
)
}
/// Available buffer sizes
static let availableBufferSizes = [512, 1024]
/// Validates and clamps settings to valid ranges
mutating func validate() {
// Clamp buffer size to valid options
if !AppSettings.availableBufferSizes.contains(bufferSize) {
bufferSize = 1024
}
// Clamp visualization mode
if lastVisualizationMode < 1 || lastVisualizationMode > 8 {
lastVisualizationMode = 1
}
// Clamp reactivity
reactivity = max(0.0, min(1.0, reactivity))
// Clamp input gain
inputGain = max(0.0, min(2.0, inputGain))
}
}
/// Codable wrapper for CGRect
struct CodableRect: Codable {
var x: Double
var y: Double
var width: Double
var height: Double
init(from rect: CGRect) {
self.x = Double(rect.origin.x)
self.y = Double(rect.origin.y)
self.width = Double(rect.size.width)
self.height = Double(rect.size.height)
}
var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}
@@ -0,0 +1,111 @@
//
// AudioAnalysisData.swift
// PsytranceVisualizer
//
// Audio analysis data structure containing all DSP results
//
import Foundation
/// Contains all audio analysis data computed by DSPEngine
struct AudioAnalysisData {
// MARK: - FFT Data
/// Raw FFT magnitude spectrum
var fftMagnitudes: [Float]
// MARK: - Mel Spectrogram
/// 64 Mel frequency bands
var melBands: [Float]
// MARK: - Sub-Bass Analysis
/// RMS energy below 100Hz (0.0 - 1.0)
var subBassEnergy: Float
/// History buffer for time-based visualization
var subBassHistory: [Float]
// MARK: - Sidechain Detection
/// Current envelope follower value (0.0 - 1.0)
var sidechainEnvelope: Float
/// Detected pumping amount (0.0 - 1.0)
var sidechainPumpAmount: Float
/// Whether pump is currently active
var isPumping: Bool
// MARK: - Harmonic-to-Noise Ratio
/// HNR ratio (0.0 = noise, 1.0 = pure harmonic)
var hnrRatio: Float
// MARK: - Transient Detection
/// Whether a transient peak was detected
var isPeak: Bool
/// Intensity of the detected peak (0.0 - 1.0)
var peakIntensity: Float
// MARK: - Stereo Channels
/// Left channel samples
var leftChannel: [Float]
/// Right channel samples
var rightChannel: [Float]
// MARK: - Additional Analysis
/// Spectral centroid (brightness) normalized 0.0 - 1.0
var spectralCentroid: Float
/// Overall RMS level
var rmsLevel: Float
// MARK: - Initialization
/// Creates an empty AudioAnalysisData with default values
static var empty: AudioAnalysisData {
AudioAnalysisData(
fftMagnitudes: [],
melBands: Array(repeating: 0, count: 64),
subBassEnergy: 0,
subBassHistory: [],
sidechainEnvelope: 0,
sidechainPumpAmount: 0,
isPumping: false,
hnrRatio: 0.5,
isPeak: false,
peakIntensity: 0,
leftChannel: [],
rightChannel: [],
spectralCentroid: 0.5,
rmsLevel: 0
)
}
/// Creates AudioAnalysisData with specified FFT size
static func create(fftSize: Int) -> AudioAnalysisData {
AudioAnalysisData(
fftMagnitudes: Array(repeating: 0, count: fftSize / 2),
melBands: Array(repeating: 0, count: 64),
subBassEnergy: 0,
subBassHistory: Array(repeating: 0, count: 128),
sidechainEnvelope: 0,
sidechainPumpAmount: 0,
isPumping: false,
hnrRatio: 0.5,
isPeak: false,
peakIntensity: 0,
leftChannel: [],
rightChannel: [],
spectralCentroid: 0.5,
rmsLevel: 0
)
}
}
@@ -0,0 +1,109 @@
//
// VisualizationMode.swift
// PsytranceVisualizer
//
// Enumeration of all available visualization modes
//
import Foundation
/// Available visualization modes, accessible via keyboard shortcuts 1-8
enum VisualizationMode: Int, CaseIterable, Codable {
case fftClassic = 1
case melSpectrogram = 2
case subBass = 3
case sidechainPump = 4
case hnr = 5
case mandelbrot = 6
case tunnelWarp = 7
case dmtGeometry = 8
/// Display name for UI
var displayName: String {
switch self {
case .fftClassic:
return "FFT Classic"
case .melSpectrogram:
return "Mel Spektrogramm"
case .subBass:
return "Sub-Bass (<100Hz)"
case .sidechainPump:
return "Sidechain Pump"
case .hnr:
return "Harmonic/Noise"
case .mandelbrot:
return "Mandelbrot"
case .tunnelWarp:
return "Tunnel Warp"
case .dmtGeometry:
return "DMT Geometry"
}
}
/// Keyboard shortcut (1-8)
var shortcut: String {
return "\(self.rawValue)"
}
/// Metal shader function name
var shaderFunctionName: String {
switch self {
case .fftClassic:
return "fftClassicFragment"
case .melSpectrogram:
return "melSpectrogramFragment"
case .subBass:
return "subBassFragment"
case .sidechainPump:
return "sidechainPumpFragment"
case .hnr:
return "hnrFragment"
case .mandelbrot:
return "mandelbrotFragment"
case .tunnelWarp:
return "tunnelWarpFragment"
case .dmtGeometry:
return "dmtGeometryFragment"
}
}
/// Description of the visualization
var description: String {
switch self {
case .fftClassic:
return "Classic frequency spectrum bars with glow effects"
case .melSpectrogram:
return "64-band Mel spectrogram with scrolling waterfall display"
case .subBass:
return "Pulsating rings visualizing sub-bass energy below 100Hz"
case .sidechainPump:
return "Breathing zoom effect synchronized to sidechain pumping"
case .hnr:
return "Harmonic vs noise visualization with geometric shapes"
case .mandelbrot:
return "Audio-reactive Mandelbrot fractal with zoom and color cycling"
case .tunnelWarp:
return "Infinite tunnel effect with warp distortion"
case .dmtGeometry:
return "Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra"
}
}
/// Creates mode from keyboard key code
static func fromKeyCode(_ keyCode: UInt16) -> VisualizationMode? {
// Key codes for 1-8 on US keyboard
let keyCodes: [UInt16: Int] = [
18: 1, // 1
19: 2, // 2
20: 3, // 3
21: 4, // 4
23: 5, // 5
22: 6, // 6
26: 7, // 7
28: 8 // 8
]
guard let modeNumber = keyCodes[keyCode] else { return nil }
return VisualizationMode(rawValue: modeNumber)
}
}
+41
View File
@@ -0,0 +1,41 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "PsytranceVisualizer",
platforms: [
.macOS(.v13)
],
products: [
.executable(
name: "PsytranceVisualizer",
targets: ["PsytranceVisualizer"]
)
],
targets: [
.executableTarget(
name: "PsytranceVisualizer",
path: ".",
exclude: [
"Package.swift",
"README.md"
],
sources: [
"App",
"Audio",
"Models",
"Rendering",
"UI",
"Utilities"
],
resources: [
.process("Resources")
],
swiftSettings: [
.unsafeFlags(["-enable-bare-slash-regex"])
]
)
]
)
@@ -0,0 +1,465 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
1100000000000001 /* PsytranceVisualizerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000001 /* PsytranceVisualizerApp.swift */; };
1100000000000002 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000002 /* AppDelegate.swift */; };
1100000000000003 /* AudioInputManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000003 /* AudioInputManager.swift */; };
1100000000000004 /* DSPEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000004 /* DSPEngine.swift */; };
1100000000000005 /* AudioAnalysisData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000005 /* AudioAnalysisData.swift */; };
1100000000000006 /* VisualizationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000006 /* VisualizationMode.swift */; };
1100000000000007 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000007 /* AppSettings.swift */; };
1100000000000008 /* MetalRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000008 /* MetalRenderer.swift */; };
1100000000000009 /* Common.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000009 /* Common.metal */; };
1100000000000010 /* FFTClassicShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000010 /* FFTClassicShader.metal */; };
1100000000000011 /* MelSpectrogramShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000011 /* MelSpectrogramShader.metal */; };
1100000000000012 /* SubBassShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000012 /* SubBassShader.metal */; };
1100000000000013 /* SidechainPumpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000013 /* SidechainPumpShader.metal */; };
1100000000000014 /* HNRShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000014 /* HNRShader.metal */; };
1100000000000015 /* MandelbrotShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000015 /* MandelbrotShader.metal */; };
1100000000000016 /* TunnelWarpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000016 /* TunnelWarpShader.metal */; };
1100000000000017 /* DMTGeometryShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000017 /* DMTGeometryShader.metal */; };
1100000000000018 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000018 /* MainWindow.swift */; };
1100000000000019 /* ControlPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000019 /* ControlPanel.swift */; };
1100000000000020 /* VisualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000020 /* VisualizerView.swift */; };
1100000000000021 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000021 /* SettingsManager.swift */; };
1100000000000022 /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000022 /* ColorPalette.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2000000000000001 /* PsytranceVisualizer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PsytranceVisualizer.app; sourceTree = BUILT_PRODUCTS_DIR; };
2100000000000001 /* PsytranceVisualizerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PsytranceVisualizerApp.swift; sourceTree = "<group>"; };
2100000000000002 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
2100000000000003 /* AudioInputManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioInputManager.swift; sourceTree = "<group>"; };
2100000000000004 /* DSPEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSPEngine.swift; sourceTree = "<group>"; };
2100000000000005 /* AudioAnalysisData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAnalysisData.swift; sourceTree = "<group>"; };
2100000000000006 /* VisualizationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizationMode.swift; sourceTree = "<group>"; };
2100000000000007 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
2100000000000008 /* MetalRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalRenderer.swift; sourceTree = "<group>"; };
2100000000000009 /* Common.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Common.metal; sourceTree = "<group>"; };
2100000000000010 /* FFTClassicShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = FFTClassicShader.metal; sourceTree = "<group>"; };
2100000000000011 /* MelSpectrogramShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MelSpectrogramShader.metal; sourceTree = "<group>"; };
2100000000000012 /* SubBassShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SubBassShader.metal; sourceTree = "<group>"; };
2100000000000013 /* SidechainPumpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SidechainPumpShader.metal; sourceTree = "<group>"; };
2100000000000014 /* HNRShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = HNRShader.metal; sourceTree = "<group>"; };
2100000000000015 /* MandelbrotShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MandelbrotShader.metal; sourceTree = "<group>"; };
2100000000000016 /* TunnelWarpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = TunnelWarpShader.metal; sourceTree = "<group>"; };
2100000000000017 /* DMTGeometryShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = DMTGeometryShader.metal; sourceTree = "<group>"; };
2100000000000018 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
2100000000000019 /* ControlPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanel.swift; sourceTree = "<group>"; };
2100000000000020 /* VisualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizerView.swift; sourceTree = "<group>"; };
2100000000000021 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
2100000000000022 /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = "<group>"; };
2100000000000023 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2100000000000024 /* PsytranceVisualizer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PsytranceVisualizer.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
3000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
4000000000000001 = {
isa = PBXGroup;
children = (
4000000000000002 /* App */,
4000000000000003 /* Audio */,
4000000000000004 /* Models */,
4000000000000005 /* Rendering */,
4000000000000007 /* UI */,
4000000000000008 /* Utilities */,
4000000000000009 /* Resources */,
4000000000000010 /* Products */,
);
sourceTree = "<group>";
};
4000000000000002 /* App */ = {
isa = PBXGroup;
children = (
2100000000000001 /* PsytranceVisualizerApp.swift */,
2100000000000002 /* AppDelegate.swift */,
);
path = App;
sourceTree = "<group>";
};
4000000000000003 /* Audio */ = {
isa = PBXGroup;
children = (
2100000000000003 /* AudioInputManager.swift */,
2100000000000004 /* DSPEngine.swift */,
);
path = Audio;
sourceTree = "<group>";
};
4000000000000004 /* Models */ = {
isa = PBXGroup;
children = (
2100000000000005 /* AudioAnalysisData.swift */,
2100000000000006 /* VisualizationMode.swift */,
2100000000000007 /* AppSettings.swift */,
);
path = Models;
sourceTree = "<group>";
};
4000000000000005 /* Rendering */ = {
isa = PBXGroup;
children = (
2100000000000008 /* MetalRenderer.swift */,
4000000000000006 /* Shaders */,
);
path = Rendering;
sourceTree = "<group>";
};
4000000000000006 /* Shaders */ = {
isa = PBXGroup;
children = (
2100000000000009 /* Common.metal */,
2100000000000010 /* FFTClassicShader.metal */,
2100000000000011 /* MelSpectrogramShader.metal */,
2100000000000012 /* SubBassShader.metal */,
2100000000000013 /* SidechainPumpShader.metal */,
2100000000000014 /* HNRShader.metal */,
2100000000000015 /* MandelbrotShader.metal */,
2100000000000016 /* TunnelWarpShader.metal */,
2100000000000017 /* DMTGeometryShader.metal */,
);
path = Shaders;
sourceTree = "<group>";
};
4000000000000007 /* UI */ = {
isa = PBXGroup;
children = (
2100000000000018 /* MainWindow.swift */,
2100000000000019 /* ControlPanel.swift */,
2100000000000020 /* VisualizerView.swift */,
);
path = UI;
sourceTree = "<group>";
};
4000000000000008 /* Utilities */ = {
isa = PBXGroup;
children = (
2100000000000021 /* SettingsManager.swift */,
2100000000000022 /* ColorPalette.swift */,
);
path = Utilities;
sourceTree = "<group>";
};
4000000000000009 /* Resources */ = {
isa = PBXGroup;
children = (
2100000000000023 /* Info.plist */,
2100000000000024 /* PsytranceVisualizer.entitlements */,
);
path = Resources;
sourceTree = "<group>";
};
4000000000000010 /* Products */ = {
isa = PBXGroup;
children = (
2000000000000001 /* PsytranceVisualizer.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
5000000000000001 /* PsytranceVisualizer */ = {
isa = PBXNativeTarget;
buildConfigurationList = 6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */;
buildPhases = (
5000000000000002 /* Sources */,
3000000000000001 /* Frameworks */,
5000000000000003 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = PsytranceVisualizer;
productName = PsytranceVisualizer;
productReference = 2000000000000001 /* PsytranceVisualizer.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
0000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
5000000000000001 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 4000000000000001;
productRefGroup = 4000000000000010 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
5000000000000001 /* PsytranceVisualizer */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
5000000000000003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1100000000000001 /* PsytranceVisualizerApp.swift in Sources */,
1100000000000002 /* AppDelegate.swift in Sources */,
1100000000000003 /* AudioInputManager.swift in Sources */,
1100000000000004 /* DSPEngine.swift in Sources */,
1100000000000005 /* AudioAnalysisData.swift in Sources */,
1100000000000006 /* VisualizationMode.swift in Sources */,
1100000000000007 /* AppSettings.swift in Sources */,
1100000000000008 /* MetalRenderer.swift in Sources */,
1100000000000009 /* Common.metal in Sources */,
1100000000000010 /* FFTClassicShader.metal in Sources */,
1100000000000011 /* MelSpectrogramShader.metal in Sources */,
1100000000000012 /* SubBassShader.metal in Sources */,
1100000000000013 /* SidechainPumpShader.metal in Sources */,
1100000000000014 /* HNRShader.metal in Sources */,
1100000000000015 /* MandelbrotShader.metal in Sources */,
1100000000000016 /* TunnelWarpShader.metal in Sources */,
1100000000000017 /* DMTGeometryShader.metal in Sources */,
1100000000000018 /* MainWindow.swift in Sources */,
1100000000000019 /* ControlPanel.swift in Sources */,
1100000000000020 /* VisualizerView.swift in Sources */,
1100000000000021 /* SettingsManager.swift in Sources */,
1100000000000022 /* ColorPalette.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
6100000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
6100000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
6100000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time.";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
6100000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time.";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6100000000000001 /* Debug */,
6100000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6100000000000003 /* Debug */,
6100000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 0000000000000001 /* Project object */;
}
@@ -0,0 +1,279 @@
//
// MetalRenderer.swift
// PsytranceVisualizer
//
// Metal-based renderer for all visualization modes
//
import MetalKit
import simd
/// Uniform data passed to all shaders
struct ShaderUniforms {
var time: Float
var resolution: SIMD2<Float>
var reactivity: Float
// Audio analysis data
var subBassEnergy: Float
var sidechainPump: Float
var sidechainEnvelope: Float
var hnrRatio: Float
var isPeak: Float
var peakIntensity: Float
var spectralCentroid: Float
var rmsLevel: Float
// Visualization mode (1-8)
var mode: Int32
// Padding for Metal alignment
var padding: SIMD2<Float> = .zero
}
/// Metal renderer managing all visualization shaders
final class MetalRenderer: NSObject, ObservableObject {
// MARK: - Properties
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private var pipelineStates: [VisualizationMode: MTLRenderPipelineState] = [:]
private var currentPipelineState: MTLRenderPipelineState?
@Published private(set) var currentMode: VisualizationMode = .fftClassic
// MARK: - Buffers
private var uniformBuffer: MTLBuffer?
private var fftBuffer: MTLBuffer?
private var melBuffer: MTLBuffer?
private var subBassHistoryBuffer: MTLBuffer?
// MARK: - State
private var startTime: CFAbsoluteTime
private var uniforms = ShaderUniforms(
time: 0,
resolution: SIMD2<Float>(1920, 1080),
reactivity: 0.5,
subBassEnergy: 0,
sidechainPump: 0,
sidechainEnvelope: 0,
hnrRatio: 0.5,
isPeak: 0,
peakIntensity: 0,
spectralCentroid: 0.5,
rmsLevel: 0,
mode: 1
)
private var audioData: AudioAnalysisData = .empty
// MARK: - Constants
private let maxFFTSize = 1024
private let melBandCount = 64
private let historySize = 128
// MARK: - Initialization
init?(device: MTLDevice) {
guard let queue = device.makeCommandQueue() else {
print("[MetalRenderer] Failed to create command queue")
return nil
}
self.device = device
self.commandQueue = queue
self.startTime = CFAbsoluteTimeGetCurrent()
super.init()
createBuffers()
loadShaders()
}
// MARK: - Public Methods
/// Sets the current visualization mode
func setVisualizationMode(_ mode: VisualizationMode) {
currentMode = mode
currentPipelineState = pipelineStates[mode]
uniforms.mode = Int32(mode.rawValue)
print("[MetalRenderer] Mode changed to: \(mode.displayName)")
}
/// Updates audio analysis data
func updateAudioData(_ data: AudioAnalysisData) {
audioData = data
// Update uniforms
uniforms.subBassEnergy = data.subBassEnergy
uniforms.sidechainPump = data.sidechainPumpAmount
uniforms.sidechainEnvelope = data.sidechainEnvelope
uniforms.hnrRatio = data.hnrRatio
uniforms.isPeak = data.isPeak ? 1.0 : 0.0
uniforms.peakIntensity = data.peakIntensity
uniforms.spectralCentroid = data.spectralCentroid
uniforms.rmsLevel = data.rmsLevel
// Update FFT buffer
updateFFTBuffer(data.fftMagnitudes)
// Update Mel buffer
updateMelBuffer(data.melBands)
// Update sub-bass history buffer
updateSubBassHistoryBuffer(data.subBassHistory)
}
/// Sets reactivity value
func setReactivity(_ value: Float) {
uniforms.reactivity = max(0.0, min(1.0, value))
}
// MARK: - Private Methods
private func createBuffers() {
// Uniform buffer
uniformBuffer = device.makeBuffer(
length: MemoryLayout<ShaderUniforms>.stride,
options: .storageModeShared
)
// FFT magnitude buffer
fftBuffer = device.makeBuffer(
length: maxFFTSize * MemoryLayout<Float>.stride,
options: .storageModeShared
)
// Mel bands buffer
melBuffer = device.makeBuffer(
length: melBandCount * MemoryLayout<Float>.stride,
options: .storageModeShared
)
// Sub-bass history buffer
subBassHistoryBuffer = device.makeBuffer(
length: historySize * MemoryLayout<Float>.stride,
options: .storageModeShared
)
}
private func updateFFTBuffer(_ magnitudes: [Float]) {
guard let buffer = fftBuffer else { return }
let count = min(magnitudes.count, maxFFTSize)
memcpy(buffer.contents(), magnitudes, count * MemoryLayout<Float>.stride)
}
private func updateMelBuffer(_ bands: [Float]) {
guard let buffer = melBuffer else { return }
let count = min(bands.count, melBandCount)
memcpy(buffer.contents(), bands, count * MemoryLayout<Float>.stride)
}
private func updateSubBassHistoryBuffer(_ history: [Float]) {
guard let buffer = subBassHistoryBuffer else { return }
let count = min(history.count, historySize)
memcpy(buffer.contents(), history, count * MemoryLayout<Float>.stride)
}
private func loadShaders() {
guard let library = device.makeDefaultLibrary() else {
print("[MetalRenderer] Failed to load shader library")
return
}
// Load vertex shader (shared)
guard let vertexFunction = library.makeFunction(name: "vertexShader") else {
print("[MetalRenderer] Failed to load vertex shader")
return
}
// Load all fragment shaders
for mode in VisualizationMode.allCases {
guard let fragmentFunction = library.makeFunction(name: mode.shaderFunctionName) else {
print("[MetalRenderer] Failed to load shader: \(mode.shaderFunctionName)")
continue
}
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = vertexFunction
descriptor.fragmentFunction = fragmentFunction
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
// Enable blending for glow effects
descriptor.colorAttachments[0].isBlendingEnabled = true
descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one
descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
do {
let pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
pipelineStates[mode] = pipelineState
print("[MetalRenderer] Loaded shader: \(mode.displayName)")
} catch {
print("[MetalRenderer] Failed to create pipeline state for \(mode.displayName): \(error)")
}
}
// Set initial pipeline state
currentPipelineState = pipelineStates[.fftClassic]
}
}
// MARK: - MTKViewDelegate
extension MetalRenderer: MTKViewDelegate {
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
uniforms.resolution = SIMD2<Float>(Float(size.width), Float(size.height))
}
func draw(in view: MTKView) {
guard let pipelineState = currentPipelineState,
let drawable = view.currentDrawable,
let renderPassDescriptor = view.currentRenderPassDescriptor else {
return
}
// Update time
uniforms.time = Float(CFAbsoluteTimeGetCurrent() - startTime)
// Update uniform buffer
if let buffer = uniformBuffer {
memcpy(buffer.contents(), &uniforms, MemoryLayout<ShaderUniforms>.stride)
}
// Create command buffer
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
return
}
// Set pipeline state
renderEncoder.setRenderPipelineState(pipelineState)
// Set buffers
if let buffer = uniformBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 0)
}
if let buffer = fftBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 1)
}
if let buffer = melBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 2)
}
if let buffer = subBassHistoryBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 3)
}
// Draw fullscreen quad
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
renderEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
@@ -0,0 +1,241 @@
//
// Common.metal
// PsytranceVisualizer
//
// Shared shader functions, types, and psytrance color palette
//
#include <metal_stdlib>
using namespace metal;
// MARK: - Uniforms Structure
struct ShaderUniforms {
float time;
float2 resolution;
float reactivity;
float subBassEnergy;
float sidechainPump;
float sidechainEnvelope;
float hnrRatio;
float isPeak;
float peakIntensity;
float spectralCentroid;
float rmsLevel;
int mode;
float2 padding;
};
// MARK: - Vertex Data
struct VertexOut {
float4 position [[position]];
float2 uv;
};
// MARK: - Psytrance Color Palette
constant float3 neonMagenta = float3(1.0, 0.0, 1.0);
constant float3 neonCyan = float3(0.0, 1.0, 1.0);
constant float3 neonGreen = float3(0.224, 1.0, 0.078);
constant float3 uvViolet = float3(0.482, 0.0, 1.0);
constant float3 hotPink = float3(1.0, 0.2, 0.6);
constant float3 electricBlue = float3(0.0, 0.5, 1.0);
constant float3 deepPurple = float3(0.1, 0.0, 0.15);
// MARK: - Palette Functions
inline float3 getPaletteColor(int index) {
switch (index % 6) {
case 0: return neonMagenta;
case 1: return neonCyan;
case 2: return neonGreen;
case 3: return uvViolet;
case 4: return hotPink;
default: return electricBlue;
}
}
inline float3 rainbowPalette(float t) {
float3 a = float3(0.5, 0.5, 0.5);
float3 b = float3(0.5, 0.5, 0.5);
float3 c = float3(1.0, 1.0, 1.0);
float3 d = float3(0.0, 0.33, 0.67);
return a + b * cos(6.28318 * (c * t + d));
}
inline float3 psytrancePalette(float t, float time) {
// Cycle through psytrance colors
float phase = fract(t + time * 0.1);
if (phase < 0.2) {
return mix(uvViolet, neonMagenta, phase * 5.0);
} else if (phase < 0.4) {
return mix(neonMagenta, hotPink, (phase - 0.2) * 5.0);
} else if (phase < 0.6) {
return mix(hotPink, neonCyan, (phase - 0.4) * 5.0);
} else if (phase < 0.8) {
return mix(neonCyan, neonGreen, (phase - 0.6) * 5.0);
} else {
return mix(neonGreen, uvViolet, (phase - 0.8) * 5.0);
}
}
// MARK: - Heatmap for Spectrogram
inline float3 heatmap(float t) {
// Low energy: dark purple
// High energy: white through neon colors
if (t < 0.2) {
return mix(float3(0.05, 0.0, 0.1), uvViolet, t * 5.0);
} else if (t < 0.4) {
return mix(uvViolet, neonMagenta, (t - 0.2) * 5.0);
} else if (t < 0.6) {
return mix(neonMagenta, hotPink, (t - 0.4) * 5.0);
} else if (t < 0.8) {
return mix(hotPink, neonCyan, (t - 0.6) * 5.0);
} else {
return mix(neonCyan, float3(1.0), (t - 0.8) * 5.0);
}
}
// MARK: - Noise Functions
// Simplex-like noise
inline float hash(float2 p) {
float3 p3 = fract(float3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
inline float noise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + float2(1.0, 0.0));
float c = hash(i + float2(0.0, 1.0));
float d = hash(i + float2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
inline float fbm(float2 p, int octaves) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < octaves; i++) {
value += amplitude * noise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
}
return value;
}
// 3D noise for volumetric effects
inline float noise3D(float3 p) {
float3 i = floor(p);
float3 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float2 uv = i.xy + float2(37.0, 17.0) * i.z;
float a = hash(uv);
float b = hash(uv + float2(1.0, 0.0));
float c = hash(uv + float2(0.0, 1.0));
float d = hash(uv + float2(1.0, 1.0));
float2 uv2 = uv + float2(37.0, 17.0);
float e = hash(uv2);
float ff = hash(uv2 + float2(1.0, 0.0));
float g = hash(uv2 + float2(0.0, 1.0));
float h = hash(uv2 + float2(1.0, 1.0));
float x1 = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float x2 = mix(mix(e, ff, f.x), mix(g, h, f.x), f.y);
return mix(x1, x2, f.z);
}
// MARK: - Utility Functions
inline float2 rotate(float2 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return float2(p.x * c - p.y * s, p.x * s + p.y * c);
}
inline float map(float value, float inMin, float inMax, float outMin, float outMax) {
return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin);
}
inline float smoothstepEdge(float edge0, float edge1, float x) {
float t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
// MARK: - Glow Effect
inline float3 addGlow(float3 color, float intensity, float3 glowColor) {
return color + glowColor * intensity * intensity;
}
// MARK: - SDF Functions for Geometry
inline float sdCircle(float2 p, float r) {
return length(p) - r;
}
inline float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
inline float sdHexagon(float2 p, float r) {
const float3 k = float3(-0.866025404, 0.5, 0.577350269);
p = abs(p);
p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
p -= float2(clamp(p.x, -k.z * r, k.z * r), r);
return length(p) * sign(p.y);
}
inline float sdStar(float2 p, float r, int n, float m) {
float an = 3.141593 / float(n);
float en = 3.141593 / m;
float2 acs = float2(cos(an), sin(an));
float2 ecs = float2(cos(en), sin(en));
float bn = fmod(atan2(p.x, p.y), 2.0 * an) - an;
p = length(p) * float2(cos(bn), abs(sin(bn)));
p -= r * acs;
p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y);
return length(p) * sign(p.x);
}
// MARK: - Vertex Shader (Fullscreen Quad)
vertex VertexOut vertexShader(uint vertexID [[vertex_id]]) {
// Generate fullscreen quad
float2 positions[4] = {
float2(-1.0, -1.0),
float2( 1.0, -1.0),
float2(-1.0, 1.0),
float2( 1.0, 1.0)
};
float2 uvs[4] = {
float2(0.0, 1.0),
float2(1.0, 1.0),
float2(0.0, 0.0),
float2(1.0, 0.0)
};
VertexOut out;
out.position = float4(positions[vertexID], 0.0, 1.0);
out.uv = uvs[vertexID];
return out;
}
@@ -0,0 +1,290 @@
//
// DMTGeometryShader.metal
// PsytranceVisualizer
//
// Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra, Hexagonal
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
// === SACRED GEOMETRY PRIMITIVES ===
// Flower of Life - overlapping circles
float flowerOfLife(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float circleRadius = 0.5;
// Center circle
result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, length(p)));
// 6 circles around center
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + time * 0.1;
float2 offset = float2(cos(angle), sin(angle)) * circleRadius;
float d = length(p - offset);
result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, d));
}
// Second ring of 12 circles
for (int i = 0; i < 12; i++) {
float angle = float(i) * 3.14159 / 6.0 + time * 0.05;
float2 offset = float2(cos(angle), sin(angle)) * circleRadius * 2.0;
float d = length(p - offset);
result = max(result, 0.5 * (1.0 - smoothstep(circleRadius - 0.02, circleRadius, d)));
}
return result;
}
// Metatron's Cube - 13 circles with connecting lines
float metatronsCube(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float nodeRadius = 0.08;
float lineWidth = 0.01;
// Define the 13 points of Metatron's Cube
float2 points[13];
points[0] = float2(0.0, 0.0); // Center
// Inner hexagon
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + time * 0.1;
points[i + 1] = float2(cos(angle), sin(angle)) * 0.5;
}
// Outer hexagon (rotated)
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + 3.14159 / 6.0 + time * 0.1;
points[i + 7] = float2(cos(angle), sin(angle)) * 0.866;
}
// Draw nodes
for (int i = 0; i < 13; i++) {
float d = length(p - points[i]);
float node = 1.0 - smoothstep(nodeRadius - 0.01, nodeRadius, d);
result = max(result, node);
}
// Draw connecting lines
for (int i = 0; i < 13; i++) {
for (int j = i + 1; j < 13; j++) {
float2 a = points[i];
float2 b = points[j];
float2 pa = p - a;
float2 ba = b - a;
float t = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
float d = length(pa - ba * t);
float line = 1.0 - smoothstep(lineWidth, lineWidth + 0.005, d);
result = max(result, line * 0.5);
}
}
return result;
}
// Sri Yantra - 9 interlocking triangles
float sriYantra(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float lineWidth = 0.015;
// Rotating factor
float rot = time * 0.05;
// Draw 4 upward triangles
for (int i = 0; i < 4; i++) {
float size = 0.3 + float(i) * 0.15;
float yOffset = -0.1 + float(i) * 0.05;
float2 tp = p - float2(0.0, yOffset);
tp = rotate(tp, rot);
// Triangle SDF
float2 a = float2(0.0, size);
float2 b = float2(-size * 0.866, -size * 0.5);
float2 c = float2(size * 0.866, -size * 0.5);
float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x)));
float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x)));
float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x)));
float triangleDist = max(max(d1, d2), d3);
float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist));
result = max(result, edge * (1.0 - float(i) * 0.15));
}
// Draw 5 downward triangles
for (int i = 0; i < 5; i++) {
float size = 0.25 + float(i) * 0.12;
float yOffset = 0.1 - float(i) * 0.04;
float2 tp = p - float2(0.0, yOffset);
tp = rotate(tp, -rot);
float2 a = float2(0.0, -size);
float2 b = float2(-size * 0.866, size * 0.5);
float2 c = float2(size * 0.866, size * 0.5);
float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x)));
float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x)));
float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x)));
float triangleDist = max(max(d1, d2), d3);
float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist));
result = max(result, edge * (1.0 - float(i) * 0.12));
}
// Central bindu (point)
float bindu = 1.0 - smoothstep(0.03, 0.04, length(p));
result = max(result, bindu);
return result;
}
// Hexagonal grid pattern
float hexagonalPattern(float2 p, float scale, float time) {
p *= scale;
// Hexagonal grid transformation
float2 s = float2(1.0, 1.732);
float2 h = s * 0.5;
float2 a = fmod(p, s) - h;
float2 b = fmod(p + h, s) - h;
float2 gv = dot(a, a) < dot(b, b) ? a : b;
float hexDist = max(abs(gv.x), dot(abs(gv), normalize(float2(1.0, 1.732))));
float edge = 1.0 - smoothstep(0.4, 0.42, hexDist);
float fill = smoothstep(0.38, 0.4, hexDist);
// Animate individual hexagons
float2 cellId = floor(p / s);
float cellPhase = hash(cellId + floor(time * 0.5)) * 2.0 * 3.14159;
float pulse = 0.5 + 0.5 * sin(time * 3.0 + cellPhase);
return edge + fill * pulse * 0.3;
}
// === MAIN FRAGMENT SHADER ===
fragment float4 dmtGeometryFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float hnr = uniforms.hnrRatio;
float peak = uniforms.isPeak;
float peakIntensity = uniforms.peakIntensity;
// Aspect ratio correction
float aspectRatio = resolution.x / resolution.y;
float2 p = (uv - 0.5) * 2.0;
p.x *= aspectRatio;
// Scale pulsing with sub-bass
float scale = 2.0 + subBass * 0.5 * (0.5 + reactivity * 0.5);
p *= scale;
// Rotation
float rotation = time * 0.1;
p = rotate(p, rotation);
// Determine which geometry to show
// Changes on peaks or every few seconds
float cycleTime = 8.0; // Seconds per geometry
float cyclePhase = fmod(time, cycleTime * 4.0) / cycleTime;
int geometryIndex = int(cyclePhase);
// Force change on strong peaks
if (peak > 0.5 && peakIntensity > 0.7) {
geometryIndex = int(fmod(float(geometryIndex) + 1.0, 4.0));
}
// Calculate all geometries (for blending)
float flower = flowerOfLife(p, 1.0, time);
float metatron = metatronsCube(p, 1.5, time);
float yantra = sriYantra(p, 1.2, time);
float hexGrid = hexagonalPattern(p, 3.0, time);
// Select primary and secondary for blending
float primary = 0.0;
float secondary = 0.0;
float blendPhase = fract(cyclePhase);
switch (geometryIndex) {
case 0:
primary = flower;
secondary = metatron;
break;
case 1:
primary = metatron;
secondary = yantra;
break;
case 2:
primary = yantra;
secondary = hexGrid;
break;
default:
primary = hexGrid;
secondary = flower;
break;
}
// Smooth transition
float transitionWindow = 0.2; // 20% of cycle for transition
float blend = smoothstep(1.0 - transitionWindow, 1.0, blendPhase);
float geometry = mix(primary, secondary, blend);
// Complexity based on HNR (more harmonic = more detail)
geometry *= 0.7 + hnr * 0.3;
// Color based on geometry and audio
float colorPhase = time * 0.1 + geometry * 0.5;
float3 geometryColor = psytrancePalette(colorPhase, time);
// Glow intensity from peak
float glowIntensity = 0.5 + peakIntensity * 0.5;
float3 glowColor = mix(neonMagenta, neonCyan, 0.5 + 0.5 * sin(time));
// Compose final color
float3 finalColor = geometryColor * geometry;
// Add glow
finalColor = addGlow(finalColor, geometry * glowIntensity, glowColor);
// Background - subtle pulsing gradient
float dist = length(uv - 0.5);
float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist);
bgColor *= 0.8 + 0.2 * subBass;
finalColor = mix(bgColor, finalColor, clamp(geometry * 1.5, 0.0, 1.0));
// Peak flash
if (peak > 0.5) {
finalColor += float3(1.0) * peakIntensity * 0.2;
}
// Outer glow
float outerGlow = exp(-dist * 3.0);
finalColor += neonMagenta * outerGlow * 0.1 * subBass;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,117 @@
//
// FFTClassicShader.metal
// PsytranceVisualizer
//
// Classic FFT bar visualization with glow effects
//
#include <metal_stdlib>
using namespace metal;
// Include common definitions
#include "Common.metal"
fragment float4 fftClassicFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
// Number of bars to display
const int numBars = 64;
const float barWidth = 1.0 / float(numBars);
const float barGap = barWidth * 0.2;
const float actualBarWidth = barWidth - barGap;
// Determine which bar this pixel belongs to
int barIndex = int(uv.x * float(numBars));
barIndex = clamp(barIndex, 0, numBars - 1);
// Get FFT magnitude for this bar (with some averaging for smoothness)
float magnitude = fftData[barIndex];
// Apply reactivity scaling
magnitude = magnitude * (0.5 + reactivity * 1.5);
magnitude = clamp(magnitude, 0.0, 1.0);
// Calculate bar position within its cell
float barCellX = fract(uv.x * float(numBars));
float barCenterX = 0.5;
// Distance from bar center (for width calculation)
float distFromCenter = abs(barCellX - barCenterX);
float halfWidth = actualBarWidth * 0.5 / barWidth;
// Check if we're inside the bar horizontally
bool insideBarX = distFromCenter < halfWidth;
// Bar height from bottom
float barHeight = magnitude;
// Add some bounce on peaks
if (uniforms.isPeak > 0.5) {
barHeight += uniforms.peakIntensity * 0.1 * sin(time * 20.0 + float(barIndex) * 0.3);
}
// Check if we're inside the bar vertically (from bottom)
float yFromBottom = 1.0 - uv.y;
bool insideBarY = yFromBottom < barHeight;
// Color based on frequency and magnitude
float colorPhase = float(barIndex) / float(numBars) + time * 0.05;
float3 barColor = psytrancePalette(colorPhase, time);
// Intensity gradient from bottom to top
float intensityGradient = yFromBottom / max(barHeight, 0.01);
intensityGradient = clamp(intensityGradient, 0.0, 1.0);
// Make top of bars brighter
barColor = mix(barColor * 0.6, barColor * 1.5, intensityGradient);
// Calculate glow
float glowRadius = 0.05 * (1.0 + magnitude);
float distToBar = 0.0;
if (!insideBarX) {
distToBar = (distFromCenter - halfWidth) * barWidth;
}
if (!insideBarY && yFromBottom >= barHeight) {
float vertDist = yFromBottom - barHeight;
distToBar = max(distToBar, vertDist);
}
float glow = exp(-distToBar * distToBar / (glowRadius * glowRadius * 2.0));
glow *= magnitude;
// Final color
float3 finalColor = float3(0.0);
if (insideBarX && insideBarY) {
// Inside the bar
finalColor = barColor;
// Add peak cap (bright line at top)
float capThickness = 0.01;
if (abs(yFromBottom - barHeight) < capThickness) {
finalColor = float3(1.0); // White cap
}
} else {
// Add glow outside bars
finalColor = barColor * glow * 0.5;
}
// Add subtle background pulse with sub-bass
float bgPulse = uniforms.subBassEnergy * 0.05;
finalColor += deepPurple * bgPulse;
// Add overall glow at peaks
if (uniforms.isPeak > 0.5) {
finalColor += neonMagenta * uniforms.peakIntensity * 0.1;
}
return float4(finalColor, 1.0);
}
@@ -0,0 +1,142 @@
//
// HNRShader.metal
// PsytranceVisualizer
//
// Harmonic-to-Noise ratio visualization with geometric shapes vs chaos
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 hnrFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float hnr = uniforms.hnrRatio;
float subBass = uniforms.subBassEnergy;
// Center coordinates
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
float2 p = uv - center;
p.x *= aspectRatio;
float dist = length(p);
float angle = atan2(p.y, p.x);
// === HARMONIC SIDE (High HNR = Clear geometric shapes) ===
// Rotating hexagon
float2 rotP = rotate(p, time * 0.5);
float hexDist = sdHexagon(rotP, 0.2 + subBass * 0.1);
float hexEdge = 1.0 - smoothstep(0.0, 0.02, abs(hexDist));
// Inner rotating triangle (star)
float2 rotP2 = rotate(p, -time * 0.3);
float starDist = sdStar(rotP2, 0.12 + subBass * 0.05, 3, 2.5);
float starEdge = 1.0 - smoothstep(0.0, 0.015, abs(starDist));
// Concentric circles
float circles = 0.0;
for (int i = 0; i < 4; i++) {
float radius = 0.1 + float(i) * 0.08 + sin(time + float(i)) * 0.02;
float circleDist = abs(dist - radius);
float circle = 1.0 - smoothstep(0.0, 0.008, circleDist);
circles += circle;
}
// Combine harmonic shapes
float harmonicShapes = hexEdge + starEdge * 0.8 + circles * 0.5;
harmonicShapes = clamp(harmonicShapes, 0.0, 1.0);
// Harmonic color - clean neon
float3 harmonicColor = mix(neonCyan, neonMagenta, 0.5 + 0.5 * sin(angle * 2.0 + time));
// === NOISE SIDE (Low HNR = Chaotic particles) ===
// Noise-based particles
float noiseField = 0.0;
for (int i = 0; i < 5; i++) {
float2 noiseP = p * (3.0 + float(i) * 2.0);
noiseP += time * float(i + 1) * 0.1;
float n = noise(noiseP);
n = pow(n, 2.0);
noiseField += n * (1.0 / float(i + 1));
}
noiseField = clamp(noiseField, 0.0, 1.0);
// Turbulent swirls
float2 turbP = p * 4.0;
float turbulence = fbm(turbP + time * 0.5, 4);
// Chaotic speckles
float speckles = 0.0;
for (int i = 0; i < 30; i++) {
float2 specklePos = float2(
hash(float2(float(i) * 0.1, time * 0.01)) - 0.5,
hash(float2(float(i) * 0.2, time * 0.01 + 0.5)) - 0.5
);
specklePos *= 0.8;
specklePos.x *= aspectRatio;
float speckleDist = length(p - specklePos);
float speckle = exp(-speckleDist * speckleDist * 500.0);
speckle *= hash(float2(float(i), floor(time * 2.0)));
speckles += speckle;
}
float noiseVisual = noiseField * 0.4 + turbulence * 0.3 + speckles * 0.3;
noiseVisual = clamp(noiseVisual, 0.0, 1.0);
// Noise color - harsh, flickering
float3 noiseColor = mix(hotPink, uvViolet, turbulence);
noiseColor *= 0.8 + 0.2 * sin(time * 20.0 + noise(p * 10.0) * 10.0);
// === BLEND based on HNR ===
// HNR determines the mix: 1.0 = pure harmonic, 0.0 = pure noise
float harmonicAmount = hnr;
float noiseAmount = 1.0 - hnr;
// Apply reactivity to make transition more dramatic
harmonicAmount = pow(harmonicAmount, 1.0 / (1.0 + reactivity));
float3 harmonicContrib = harmonicColor * harmonicShapes * harmonicAmount;
float3 noiseContrib = noiseColor * noiseVisual * noiseAmount;
float3 finalColor = harmonicContrib + noiseContrib;
// Add center indicator showing current HNR
float indicator = smoothstep(0.25, 0.24, dist) - smoothstep(0.24, 0.23, dist);
float indicatorFill = smoothstep(0.23, 0.22, dist);
// Split indicator by HNR
float harmonicSide = step(0.0, p.x);
float noiseSide = 1.0 - harmonicSide;
finalColor += neonCyan * indicator * 0.3;
finalColor += neonCyan * indicatorFill * harmonicSide * hnr * 0.2;
finalColor += hotPink * indicatorFill * noiseSide * (1.0 - hnr) * 0.2;
// Background glow
float bgGlow = exp(-dist * dist * 4.0);
float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist);
finalColor += bgColor * (1.0 - clamp(harmonicShapes + noiseVisual, 0.0, 1.0));
// Peak flash
if (uniforms.isPeak > 0.5) {
finalColor += float3(1.0) * uniforms.peakIntensity * 0.15 * exp(-dist * 3.0);
}
return float4(finalColor, 1.0);
}
@@ -0,0 +1,121 @@
//
// MandelbrotShader.metal
// PsytranceVisualizer
//
// Audio-reactive Mandelbrot fractal with zoom and color cycling
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 mandelbrotFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float pump = uniforms.sidechainPump;
float centroid = uniforms.spectralCentroid;
// Aspect ratio correction
float aspectRatio = resolution.x / resolution.y;
// Map UV to complex plane
float2 c = (uv - 0.5) * 2.0;
c.x *= aspectRatio;
// Audio-reactive zoom level
// Base zoom increases over time, modulated by sub-bass
float baseZoom = 1.0 + time * 0.02;
float audioZoom = subBass * 0.5 * (0.5 + reactivity * 0.5);
float zoom = pow(2.0, baseZoom + audioZoom);
// Zoom center - drifts based on sidechain
float2 zoomCenter = float2(-0.7, 0.0);
zoomCenter.x += sin(time * 0.1) * 0.3 + pump * 0.1 * sin(time);
zoomCenter.y += cos(time * 0.13) * 0.2 + pump * 0.1 * cos(time);
// Apply zoom
c = c / zoom + zoomCenter;
// Mandelbrot iteration
float2 z = float2(0.0);
int maxIterations = int(50.0 + reactivity * 100.0);
int iterations = 0;
float smoothIter = 0.0;
for (int i = 0; i < 150; i++) {
if (i >= maxIterations) break;
// z = z^2 + c
float2 zNew = float2(
z.x * z.x - z.y * z.y + c.x,
2.0 * z.x * z.y + c.y
);
z = zNew;
float mag2 = dot(z, z);
if (mag2 > 256.0) {
// Smooth iteration count
smoothIter = float(i) - log2(log2(mag2)) + 4.0;
break;
}
iterations = i;
}
// Normalize iteration count
float normalizedIter = smoothIter / float(maxIterations);
// Color based on iterations
float3 color;
if (iterations >= maxIterations - 1) {
// Inside the set - deep color
color = deepPurple * (0.5 + 0.5 * subBass);
} else {
// Outside - color cycling based on iterations and audio
float colorPhase = normalizedIter + time * 0.1 + centroid;
// Use psytrance palette with color rotation
color = psytrancePalette(colorPhase, time);
// Modulate brightness by iteration depth
float brightness = 0.5 + 0.5 * sin(smoothIter * 0.3);
color *= brightness;
// Add glow at boundary
float edgeFactor = 1.0 - normalizedIter;
edgeFactor = pow(edgeFactor, 3.0);
color = addGlow(color, edgeFactor * 0.5, neonCyan);
}
// Sub-bass pulse effect
color *= 0.8 + 0.2 * subBass;
// Sidechain breathing
float breathe = 1.0 + pump * 0.1;
color *= breathe;
// Peak flash in bright areas
if (uniforms.isPeak > 0.5 && iterations < maxIterations - 1) {
color += neonMagenta * uniforms.peakIntensity * 0.2 * normalizedIter;
}
// Subtle vignette
float2 vignetteuv = uv - 0.5;
float vignette = 1.0 - dot(vignetteuv, vignetteuv) * 0.5;
color *= vignette;
return float4(color, 1.0);
}
@@ -0,0 +1,95 @@
//
// MelSpectrogramShader.metal
// PsytranceVisualizer
//
// Mel spectrogram with scrolling waterfall display
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 melSpectrogramFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
// Configuration
const int numBands = 64;
const int historyLength = 128;
// Map UV to mel band and history position
int bandIndex = int(uv.x * float(numBands));
bandIndex = clamp(bandIndex, 0, numBands - 1);
// Scrolling effect - newer data at bottom
float scrollOffset = fract(time * 0.5); // Scroll speed
float yPos = fract(uv.y + scrollOffset);
// Get mel magnitude
float magnitude = melData[bandIndex];
magnitude = magnitude * (0.5 + reactivity * 1.5);
magnitude = clamp(magnitude, 0.0, 1.0);
// Create waterfall effect using history
int historyIndex = int(yPos * float(historyLength));
historyIndex = clamp(historyIndex, 0, historyLength - 1);
// Combine current and historical data for waterfall
float historicalValue = historyData[historyIndex];
// Blend between current magnitude and position-based intensity
float intensity = magnitude;
// Add some variance based on band position
float bandPhase = float(bandIndex) / float(numBands);
intensity *= 0.8 + 0.2 * sin(bandPhase * 6.28318 + time);
// Apply fade for older data (top of screen)
float ageFade = 1.0 - uv.y * 0.3;
intensity *= ageFade;
// Generate color using heatmap
float3 color = heatmap(intensity);
// Add frequency-dependent hue shift
float hueShift = bandPhase * 0.3;
color = psytrancePalette(intensity + hueShift, time);
// Modulate by actual intensity
color *= 0.3 + intensity * 0.7;
// Add grid lines for visual reference
float gridX = abs(fract(uv.x * float(numBands)) - 0.5) * 2.0;
float gridY = abs(fract(uv.y * 16.0) - 0.5) * 2.0;
float gridLine = smoothstep(0.95, 1.0, gridX) + smoothstep(0.95, 1.0, gridY);
gridLine *= 0.1;
color += float3(gridLine) * uvViolet;
// Add glow on high energy
if (intensity > 0.7) {
float glow = (intensity - 0.7) / 0.3;
color = addGlow(color, glow * 0.5, neonCyan);
}
// Peak flash
if (uniforms.isPeak > 0.5) {
color += neonMagenta * uniforms.peakIntensity * 0.15;
}
// Sub-bass emphasis on lower bands
if (bandIndex < 8) {
color += uvViolet * uniforms.subBassEnergy * 0.3;
}
return float4(color, 1.0);
}
@@ -0,0 +1,130 @@
//
// SidechainPumpShader.metal
// PsytranceVisualizer
//
// Visualizes sidechain pumping with breathing zoom effect
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 sidechainPumpFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float pump = uniforms.sidechainPump;
float envelope = uniforms.sidechainEnvelope;
float subBass = uniforms.subBassEnergy;
// Center and aspect ratio correction
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
float2 p = uv - center;
p.x *= aspectRatio;
// Apply breathing zoom effect
float zoomAmount = 1.0 + pump * 0.3 * (0.5 + reactivity * 0.5);
p /= zoomAmount;
// Radial distortion synchronized with pump
float dist = length(p);
float angle = atan2(p.y, p.x);
// Pump-synced radial waves
float radialWave = sin(dist * 15.0 - time * 3.0 + envelope * 10.0);
radialWave *= pump * 0.3;
// Apply distortion
float2 distortedP = p;
distortedP *= 1.0 + radialWave * 0.1;
// Create concentric pulse rings
float rings = 0.0;
const int numRings = 5;
for (int i = 0; i < numRings; i++) {
float ringPhase = fract(time * 0.5 + float(i) * 0.2 - envelope * 0.5);
float ringRadius = ringPhase * 0.6;
float ringWidth = 0.02 + pump * 0.03;
float ringDist = abs(dist - ringRadius);
float ring = exp(-ringDist * ringDist / (ringWidth * ringWidth));
ring *= 1.0 - ringPhase; // Fade out as it expands
ring *= pump;
rings += ring;
}
// Breathing glow in center
float breathIntensity = 0.5 + 0.5 * sin(time * 4.0 + envelope * 6.28318);
breathIntensity *= pump;
float centerGlow = exp(-dist * dist * 8.0);
centerGlow *= breathIntensity;
// Color based on pump phase
float3 pumpColor = mix(uvViolet, neonMagenta, envelope);
float3 ringColor = mix(neonCyan, hotPink, pump);
// Background pattern - angular sectors that pulse
float sectors = 8.0;
float sectorAngle = fract(angle / (2.0 * 3.14159) * sectors);
float sectorPulse = smoothstep(0.4, 0.5, sectorAngle) - smoothstep(0.5, 0.6, sectorAngle);
sectorPulse *= pump * 0.3;
sectorPulse *= exp(-dist * 3.0);
// Spiral pattern
float spiral = fract(angle / (2.0 * 3.14159) * 3.0 + dist * 5.0 - time * 0.5);
spiral = smoothstep(0.4, 0.5, spiral) - smoothstep(0.5, 0.6, spiral);
spiral *= pump * 0.2;
spiral *= exp(-dist * 2.0);
// Compose final color
float3 finalColor = float3(0.0);
// Base gradient
float3 bgGradient = mix(deepPurple, uvViolet * 0.3, dist);
finalColor += bgGradient;
// Add rings
finalColor += ringColor * rings;
// Add center glow
finalColor += pumpColor * centerGlow;
// Add sector pulse
finalColor += neonGreen * sectorPulse;
// Add spiral
finalColor += electricBlue * spiral;
// Screen flash on strong pump
if (pump > 0.7) {
float flash = (pump - 0.7) / 0.3;
flash *= 0.2;
finalColor += neonMagenta * flash;
}
// Peak highlight
if (uniforms.isPeak > 0.5) {
float peakFlash = uniforms.peakIntensity * 0.2;
finalColor += float3(1.0) * peakFlash * exp(-dist * 5.0);
}
// Vignette
float vignette = 1.0 - smoothstep(0.4, 0.8, dist);
finalColor *= 0.7 + vignette * 0.3;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,116 @@
//
// SubBassShader.metal
// PsytranceVisualizer
//
// Pulsating rings visualizing sub-bass energy below 100Hz
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 subBassFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
// Center coordinates
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
// Correct for aspect ratio
float2 p = uv - center;
p.x *= aspectRatio;
float dist = length(p);
float angle = atan2(p.y, p.x);
// Main pulsating circle
float baseRadius = 0.15;
float pulseAmount = subBass * (0.5 + reactivity * 0.5);
float mainRadius = baseRadius + pulseAmount * 0.2;
// Add wobble based on angle
float wobble = sin(angle * 4.0 + time * 2.0) * 0.02 * subBass;
mainRadius += wobble;
// Core circle
float coreDist = abs(dist - mainRadius);
float coreGlow = exp(-coreDist * coreDist * 200.0);
// Inner fill with gradient
float innerFill = smoothstep(mainRadius, mainRadius * 0.3, dist);
innerFill *= 0.5 + 0.5 * subBass;
// Expanding rings
const int numRings = 6;
float ringIntensity = 0.0;
for (int i = 0; i < numRings; i++) {
// Each ring expands outward over time
float ringPhase = fract(time * 0.3 - float(i) * 0.15);
float ringRadius = mainRadius + ringPhase * 0.5;
// Get historical sub-bass value for this ring
int histIndex = clamp(int(ringPhase * 64.0), 0, 63);
float histValue = historyData[histIndex];
// Ring thickness based on historical energy
float thickness = 0.005 + histValue * 0.01;
float ringDist = abs(dist - ringRadius);
// Ring visibility
float ring = exp(-ringDist * ringDist / (thickness * thickness));
ring *= (1.0 - ringPhase); // Fade as it expands
ring *= histValue; // Intensity based on history
ringIntensity += ring;
}
// Color composition
float3 coreColor = mix(uvViolet, neonMagenta, subBass);
float3 ringColor = mix(neonMagenta, hotPink, 0.5 + 0.5 * sin(time));
float3 finalColor = float3(0.0);
// Add core
finalColor += coreColor * (innerFill + coreGlow * 2.0);
// Add rings
finalColor += ringColor * ringIntensity * 0.8;
// Add central glow
float centerGlow = exp(-dist * dist * 10.0) * subBass;
finalColor += uvViolet * centerGlow * 0.5;
// Add angular rays on peaks
if (uniforms.isPeak > 0.5) {
float rays = abs(sin(angle * 8.0 + time * 5.0));
rays = pow(rays, 4.0) * exp(-dist * 2.0);
rays *= uniforms.peakIntensity;
finalColor += neonCyan * rays * 0.5;
}
// Outer vignette
float vignette = 1.0 - smoothstep(0.3, 0.8, dist);
finalColor *= vignette;
// Background pulse
float bgPulse = subBass * 0.1;
finalColor += deepPurple * bgPulse;
// Add noise texture for organic feel
float noiseVal = noise(p * 20.0 + time);
finalColor += uvViolet * noiseVal * 0.02 * subBass;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,136 @@
//
// TunnelWarpShader.metal
// PsytranceVisualizer
//
// Infinite tunnel effect with warp distortion
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 tunnelWarpFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float pump = uniforms.sidechainPump;
float hnr = uniforms.hnrRatio;
// Center and aspect correction
float aspectRatio = resolution.x / resolution.y;
float2 p = (uv - 0.5) * 2.0;
p.x *= aspectRatio;
// Convert to polar coordinates for tunnel
float dist = length(p);
float angle = atan2(p.y, p.x);
// Avoid division by zero at center
dist = max(dist, 0.001);
// Tunnel depth (inverse of distance)
float depth = 1.0 / dist;
// Speed controlled by sub-bass
float baseSpeed = 2.0;
float audioSpeed = subBass * 3.0 * (0.5 + reactivity * 0.5);
float speed = baseSpeed + audioSpeed;
// Warp distortion from sidechain pump
float warpAmount = pump * 0.5;
depth += sin(angle * 4.0 + time * 2.0) * warpAmount * 0.5;
angle += sin(depth * 2.0 + time) * warpAmount * 0.3;
// Create tunnel coordinates
float2 tunnelUV = float2(
angle / (2.0 * 3.14159) + 0.5, // Angular coordinate [0, 1]
depth + time * speed // Depth with movement
);
// === TUNNEL WALL PATTERNS ===
// Hexagonal grid pattern
float2 hexUV = tunnelUV * float2(8.0, 2.0);
float2 hexCell = floor(hexUV);
float2 hexFrac = fract(hexUV);
// Offset every other row
if (fmod(hexCell.y, 2.0) > 0.5) {
hexFrac.x = fract(hexFrac.x + 0.5);
}
float hexDist = length(hexFrac - 0.5);
float hexPattern = smoothstep(0.4, 0.35, hexDist);
// Add concentric rings
float rings = sin(tunnelUV.y * 20.0) * 0.5 + 0.5;
rings = smoothstep(0.3, 0.7, rings);
// Angular segments
float segments = 8.0;
float angularLines = abs(sin(angle * segments));
angularLines = smoothstep(0.95, 1.0, angularLines);
// Combine patterns
float pattern = hexPattern * 0.5 + rings * 0.3 + angularLines * 0.2;
// === COLORING ===
// Base color cycles with depth and time
float colorPhase = tunnelUV.y * 0.1 + time * 0.2;
float3 tunnelColor = psytrancePalette(colorPhase, time);
// Depth fog (darker towards center/infinity)
float fog = exp(-dist * 2.0);
tunnelColor *= fog;
// Pattern overlay
float3 patternColor = mix(uvViolet, neonCyan, rings);
tunnelColor = mix(tunnelColor, patternColor, pattern * 0.5);
// Edge glow (bright at tunnel edges)
float edgeGlow = exp(-dist * 5.0);
tunnelColor = addGlow(tunnelColor, (1.0 - edgeGlow) * 0.3, neonMagenta);
// Center light (looking into the tunnel)
float centerLight = exp(-dist * dist * 50.0);
tunnelColor += float3(1.0) * centerLight * 0.5;
// HNR affects pattern complexity
float patternIntensity = hnr;
tunnelColor *= 0.7 + patternIntensity * 0.3;
// Add noise for texture
float noiseVal = noise(tunnelUV * 10.0 + time);
tunnelColor += uvViolet * noiseVal * 0.1;
// Pump flash
if (pump > 0.5) {
float pumpFlash = (pump - 0.5) * 2.0;
tunnelColor += neonMagenta * pumpFlash * 0.2;
}
// Peak flash
if (uniforms.isPeak > 0.5) {
float peakFlash = uniforms.peakIntensity;
tunnelColor += float3(1.0) * peakFlash * 0.15 * (1.0 - edgeGlow);
}
// Speed lines effect
float speedLines = fract(tunnelUV.y * 50.0 - time * speed * 2.0);
speedLines = smoothstep(0.95, 1.0, speedLines);
speedLines *= subBass * 0.5;
tunnelColor += neonCyan * speedLines;
return float4(tunnelColor, 1.0);
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2024. All rights reserved.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Psytrance Visualizer needs access to your audio input to visualize music in real-time. You can use a virtual audio device like BlackHole to route system audio.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
+315
View File
@@ -0,0 +1,315 @@
//
// ControlPanel.swift
// PsytranceVisualizer
//
// Auto-hiding control panel with audio and visualization settings
//
import AppKit
import Combine
/// Delegate protocol for control panel actions
protocol ControlPanelDelegate: AnyObject {
func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String)
func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int)
func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode)
func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float)
func controlPanelDidRequestFullscreen(_ panel: ControlPanel)
}
/// Auto-hiding control panel overlay
final class ControlPanel: NSView {
// MARK: - Properties
weak var delegate: ControlPanelDelegate?
private var isVisible = true
private var hideTimer: Timer?
private let hideDelay: TimeInterval = 3.0
private var audioDevices: [AudioDevice] = []
private var selectedMode: VisualizationMode = .fftClassic
// MARK: - UI Elements
private let containerView = NSVisualEffectView()
private let devicePopup = NSPopUpButton()
private let bufferSizePopup = NSPopUpButton()
private let modeSegment = NSSegmentedControl()
private let reactivitySlider = NSSlider()
private let reactivityLabel = NSTextField(labelWithString: "Reactivity")
private let fullscreenButton = NSButton()
// MARK: - Layout Constants
private let panelHeight: CGFloat = 60
private let padding: CGFloat = 12
private let elementHeight: CGFloat = 24
// MARK: - Initialization
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupUI()
setupConstraints()
startHideTimer()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
// Container with vibrancy effect
containerView.material = .hudWindow
containerView.blendingMode = .behindWindow
containerView.state = .active
containerView.wantsLayer = true
containerView.layer?.cornerRadius = 12
containerView.layer?.masksToBounds = true
addSubview(containerView)
// Device popup
devicePopup.target = self
devicePopup.action = #selector(deviceChanged)
devicePopup.controlSize = .small
devicePopup.font = .systemFont(ofSize: 11)
containerView.addSubview(devicePopup)
// Buffer size popup
bufferSizePopup.target = self
bufferSizePopup.action = #selector(bufferSizeChanged)
bufferSizePopup.controlSize = .small
bufferSizePopup.font = .systemFont(ofSize: 11)
bufferSizePopup.addItems(withTitles: ["512", "1024"])
bufferSizePopup.selectItem(withTitle: "1024")
containerView.addSubview(bufferSizePopup)
// Mode segment control
modeSegment.segmentCount = 8
for mode in VisualizationMode.allCases {
modeSegment.setLabel(mode.shortcut, forSegment: mode.rawValue - 1)
modeSegment.setToolTip(mode.displayName, forSegment: mode.rawValue - 1)
}
modeSegment.selectedSegment = 0
modeSegment.target = self
modeSegment.action = #selector(modeChanged)
modeSegment.controlSize = .small
modeSegment.segmentStyle = .capsule
containerView.addSubview(modeSegment)
// Reactivity label
reactivityLabel.font = .systemFont(ofSize: 10)
reactivityLabel.textColor = .secondaryLabelColor
containerView.addSubview(reactivityLabel)
// Reactivity slider
reactivitySlider.minValue = 0.0
reactivitySlider.maxValue = 1.0
reactivitySlider.doubleValue = 0.5
reactivitySlider.target = self
reactivitySlider.action = #selector(reactivityChanged)
reactivitySlider.controlSize = .small
containerView.addSubview(reactivitySlider)
// Fullscreen button
fullscreenButton.title = ""
fullscreenButton.bezelStyle = .accessoryBarAction
fullscreenButton.target = self
fullscreenButton.action = #selector(fullscreenClicked)
fullscreenButton.toolTip = "Toggle Fullscreen (F)"
containerView.addSubview(fullscreenButton)
// Set colors
applyPsytranceTheme()
}
private func applyPsytranceTheme() {
// Custom appearance for psytrance aesthetic
containerView.appearance = NSAppearance(named: .darkAqua)
}
private func setupConstraints() {
containerView.translatesAutoresizingMaskIntoConstraints = false
devicePopup.translatesAutoresizingMaskIntoConstraints = false
bufferSizePopup.translatesAutoresizingMaskIntoConstraints = false
modeSegment.translatesAutoresizingMaskIntoConstraints = false
reactivityLabel.translatesAutoresizingMaskIntoConstraints = false
reactivitySlider.translatesAutoresizingMaskIntoConstraints = false
fullscreenButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// Container
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding),
containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding),
containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding),
containerView.heightAnchor.constraint(equalToConstant: panelHeight),
// Device popup
devicePopup.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding),
devicePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
devicePopup.widthAnchor.constraint(equalToConstant: 150),
// Buffer size popup
bufferSizePopup.leadingAnchor.constraint(equalTo: devicePopup.trailingAnchor, constant: 8),
bufferSizePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
bufferSizePopup.widthAnchor.constraint(equalToConstant: 60),
// Mode segment
modeSegment.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
modeSegment.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
// Reactivity label
reactivityLabel.trailingAnchor.constraint(equalTo: reactivitySlider.leadingAnchor, constant: -4),
reactivityLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
// Reactivity slider
reactivitySlider.trailingAnchor.constraint(equalTo: fullscreenButton.leadingAnchor, constant: -padding),
reactivitySlider.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
reactivitySlider.widthAnchor.constraint(equalToConstant: 80),
// Fullscreen button
fullscreenButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding),
fullscreenButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
])
}
// MARK: - Public Methods
/// Updates the list of available audio devices
func updateDevices(_ devices: [AudioDevice], selectedUID: String?) {
audioDevices = devices
devicePopup.removeAllItems()
for device in devices {
devicePopup.addItem(withTitle: device.name)
devicePopup.lastItem?.representedObject = device.uid
}
if let uid = selectedUID,
let index = devices.firstIndex(where: { $0.uid == uid }) {
devicePopup.selectItem(at: index)
}
}
/// Updates the selected buffer size
func updateBufferSize(_ size: Int) {
bufferSizePopup.selectItem(withTitle: "\(size)")
}
/// Updates the selected visualization mode
func updateMode(_ mode: VisualizationMode) {
selectedMode = mode
modeSegment.selectedSegment = mode.rawValue - 1
}
/// Updates the reactivity slider
func updateReactivity(_ value: Float) {
reactivitySlider.doubleValue = Double(value)
}
/// Shows the control panel
func show(animated: Bool = true) {
guard !isVisible else { return }
isVisible = true
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
self.animator().alphaValue = 1.0
}
} else {
alphaValue = 1.0
}
startHideTimer()
}
/// Hides the control panel
func hide(animated: Bool = true) {
guard isVisible else { return }
isVisible = false
hideTimer?.invalidate()
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
self.animator().alphaValue = 0.0
}
} else {
alphaValue = 0.0
}
}
/// Resets the hide timer (call on mouse movement)
func resetHideTimer() {
show()
startHideTimer()
}
// MARK: - Private Methods
private func startHideTimer() {
hideTimer?.invalidate()
hideTimer = Timer.scheduledTimer(withTimeInterval: hideDelay, repeats: false) { [weak self] _ in
self?.hide()
}
}
// MARK: - Actions
@objc private func deviceChanged() {
guard let uid = devicePopup.selectedItem?.representedObject as? String else { return }
delegate?.controlPanel(self, didSelectDevice: uid)
}
@objc private func bufferSizeChanged() {
guard let title = bufferSizePopup.selectedItem?.title,
let size = Int(title) else { return }
delegate?.controlPanel(self, didSelectBufferSize: size)
}
@objc private func modeChanged() {
let modeIndex = modeSegment.selectedSegment + 1
guard let mode = VisualizationMode(rawValue: modeIndex) else { return }
selectedMode = mode
delegate?.controlPanel(self, didSelectMode: mode)
}
@objc private func reactivityChanged() {
let value = Float(reactivitySlider.doubleValue)
delegate?.controlPanel(self, didChangeReactivity: value)
}
@objc private func fullscreenClicked() {
delegate?.controlPanelDidRequestFullscreen(self)
}
// MARK: - Mouse Tracking
override func updateTrackingAreas() {
super.updateTrackingAreas()
// Remove existing tracking areas
for area in trackingAreas {
removeTrackingArea(area)
}
// Add new tracking area
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeAlways]
let trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
addTrackingArea(trackingArea)
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
resetHideTimer()
}
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
show()
}
}
+323
View File
@@ -0,0 +1,323 @@
//
// MainWindow.swift
// PsytranceVisualizer
//
// Main application window with keyboard handling
//
import AppKit
import Combine
/// Main window controller for the visualizer
final class MainWindowController: NSWindowController {
// MARK: - Properties
private var visualizerView: VisualizerView!
private var controlPanel: ControlPanel!
private var audioManager: AudioInputManager!
private var dspEngine: DSPEngine!
private var settingsManager: SettingsManager { .shared }
private var cancellables = Set<AnyCancellable>()
private var displayLink: CVDisplayLink?
// MARK: - Initialization
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 720),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "Psytrance Visualizer"
window.minSize = NSSize(width: 800, height: 600)
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.backgroundColor = .black
window.collectionBehavior = [.fullScreenPrimary]
// Restore window frame if saved
if let savedFrame = SettingsManager.shared.settings.windowFrame?.cgRect {
window.setFrame(savedFrame, display: false)
} else {
window.center()
}
self.init(window: window)
setupContent()
setupAudio()
setupKeyboardHandling()
restoreSettings()
}
// MARK: - Setup
private func setupContent() {
guard let contentView = window?.contentView else { return }
// Visualizer view (fills entire window)
visualizerView = VisualizerView()
visualizerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(visualizerView)
// Control panel (overlay at bottom)
controlPanel = ControlPanel()
controlPanel.translatesAutoresizingMaskIntoConstraints = false
controlPanel.delegate = self
contentView.addSubview(controlPanel)
NSLayoutConstraint.activate([
// Visualizer fills entire window
visualizerView.topAnchor.constraint(equalTo: contentView.topAnchor),
visualizerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
visualizerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
visualizerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// Control panel at bottom
controlPanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
controlPanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
controlPanel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
controlPanel.heightAnchor.constraint(equalToConstant: 90),
])
// Mouse tracking for control panel
setupMouseTracking()
}
private func setupAudio() {
audioManager = AudioInputManager()
dspEngine = DSPEngine(bufferSize: settingsManager.settings.bufferSize)
// Audio buffer callback
audioManager.onAudioBuffer = { [weak self] buffer in
guard let self = self else { return }
let analysisData = self.dspEngine.process(buffer: buffer)
DispatchQueue.main.async {
self.visualizerView.updateAudioData(analysisData)
}
}
// Update control panel when devices change
audioManager.$availableDevices
.receive(on: DispatchQueue.main)
.sink { [weak self] devices in
self?.controlPanel.updateDevices(
devices,
selectedUID: self?.settingsManager.settings.selectedAudioDeviceUID
)
}
.store(in: &cancellables)
// Start audio
audioManager.start()
}
private func setupKeyboardHandling() {
// Monitor for key events
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
if self?.handleKeyDown(event) == true {
return nil // Event handled
}
return event
}
}
private func setupMouseTracking() {
guard let contentView = window?.contentView else { return }
let options: NSTrackingArea.Options = [.mouseMoved, .activeAlways, .inVisibleRect]
let trackingArea = NSTrackingArea(
rect: contentView.bounds,
options: options,
owner: self,
userInfo: nil
)
contentView.addTrackingArea(trackingArea)
}
private func restoreSettings() {
let settings = settingsManager.settings
// Restore visualization mode
if let mode = VisualizationMode(rawValue: settings.lastVisualizationMode) {
visualizerView.setVisualizationMode(mode)
controlPanel.updateMode(mode)
}
// Restore reactivity
visualizerView.setReactivity(settings.reactivity)
dspEngine.setReactivity(settings.reactivity)
controlPanel.updateReactivity(settings.reactivity)
// Restore buffer size
dspEngine.setBufferSize(settings.bufferSize)
audioManager.setBufferSize(settings.bufferSize)
controlPanel.updateBufferSize(settings.bufferSize)
// Restore audio device
if let deviceUID = settings.selectedAudioDeviceUID {
audioManager.selectDevice(uid: deviceUID)
}
// Restore fullscreen state
if settings.isFullscreen {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.window?.toggleFullScreen(nil)
}
}
}
// MARK: - Keyboard Handling
private func handleKeyDown(_ event: NSEvent) -> Bool {
// Check for visualization mode shortcuts (1-8)
if let mode = VisualizationMode.fromKeyCode(event.keyCode) {
setVisualizationMode(mode)
return true
}
// Other keyboard shortcuts
switch event.keyCode {
case 3: // F key
toggleFullscreen()
return true
case 53: // Escape
if window?.styleMask.contains(.fullScreen) == true {
window?.toggleFullScreen(nil)
}
return true
case 49: // Space
// Toggle pause (could be implemented)
return true
default:
break
}
// Cmd+F for fullscreen
if event.modifierFlags.contains(.command) && event.keyCode == 3 {
toggleFullscreen()
return true
}
return false
}
// MARK: - Mode Switching
private func setVisualizationMode(_ mode: VisualizationMode) {
visualizerView.setVisualizationMode(mode)
controlPanel.updateMode(mode)
settingsManager.setVisualizationMode(mode)
}
// MARK: - Fullscreen
private func toggleFullscreen() {
window?.toggleFullScreen(nil)
}
// MARK: - Mouse Events
override func mouseMoved(with event: NSEvent) {
controlPanel.resetHideTimer()
}
// MARK: - Window Events
override func windowDidLoad() {
super.windowDidLoad()
// Save window frame on move/resize
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidResize),
name: NSWindow.didResizeNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidMove),
name: NSWindow.didMoveNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidEnterFullScreen),
name: NSWindow.didEnterFullScreenNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidExitFullScreen),
name: NSWindow.didExitFullScreenNotification,
object: window
)
}
@objc private func windowDidResize(_ notification: Notification) {
if let frame = window?.frame {
settingsManager.setWindowFrame(frame)
}
}
@objc private func windowDidMove(_ notification: Notification) {
if let frame = window?.frame {
settingsManager.setWindowFrame(frame)
}
}
@objc private func windowDidEnterFullScreen(_ notification: Notification) {
settingsManager.setFullscreen(true)
controlPanel.hide()
}
@objc private func windowDidExitFullScreen(_ notification: Notification) {
settingsManager.setFullscreen(false)
controlPanel.show()
}
// MARK: - Cleanup
deinit {
audioManager.stop()
settingsManager.saveNow()
}
}
// MARK: - ControlPanelDelegate
extension MainWindowController: ControlPanelDelegate {
func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String) {
audioManager.selectDevice(uid: uid)
settingsManager.setAudioDevice(uid: uid)
}
func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int) {
audioManager.setBufferSize(size)
dspEngine.setBufferSize(size)
settingsManager.setBufferSize(size)
}
func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode) {
setVisualizationMode(mode)
}
func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float) {
visualizerView.setReactivity(value)
dspEngine.setReactivity(value)
settingsManager.setReactivity(value)
}
func controlPanelDidRequestFullscreen(_ panel: ControlPanel) {
toggleFullscreen()
}
}
+122
View File
@@ -0,0 +1,122 @@
//
// VisualizerView.swift
// PsytranceVisualizer
//
// MTKView subclass for rendering visualizations
//
import MetalKit
import Combine
/// MTKView subclass that displays audio-reactive visualizations
final class VisualizerView: MTKView {
// MARK: - Properties
private var renderer: MetalRenderer?
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init() {
// Get default Metal device
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("Metal is not supported on this device")
}
super.init(frame: .zero, device: device)
configure()
setupRenderer()
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Configuration
private func configure() {
// Background color
clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
// Color format
colorPixelFormat = .bgra8Unorm
// Enable display link for smooth rendering
isPaused = false
enableSetNeedsDisplay = false
// Use display refresh rate
preferredFramesPerSecond = 120 // Will cap to display refresh
// Layer configuration
layer?.isOpaque = true
// Allow high DPI
layer?.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
}
private func setupRenderer() {
guard let device = device else { return }
renderer = MetalRenderer(device: device)
delegate = renderer
// Initial size update
if let renderer = renderer {
let size = drawableSize
renderer.mtkView(self, drawableSizeWillChange: size)
}
}
// MARK: - Public Methods
/// Returns the Metal renderer
func getRenderer() -> MetalRenderer? {
return renderer
}
/// Updates audio data for visualization
func updateAudioData(_ data: AudioAnalysisData) {
renderer?.updateAudioData(data)
}
/// Sets the visualization mode
func setVisualizationMode(_ mode: VisualizationMode) {
renderer?.setVisualizationMode(mode)
}
/// Sets reactivity value
func setReactivity(_ value: Float) {
renderer?.setReactivity(value)
}
/// Gets current visualization mode
var currentMode: VisualizationMode {
renderer?.currentMode ?? .fftClassic
}
}
// MARK: - SwiftUI Bridge
#if canImport(SwiftUI)
import SwiftUI
/// SwiftUI wrapper for VisualizerView
struct VisualizerViewRepresentable: NSViewRepresentable {
@Binding var audioData: AudioAnalysisData
@Binding var mode: VisualizationMode
@Binding var reactivity: Float
func makeNSView(context: Context) -> VisualizerView {
let view = VisualizerView()
return view
}
func updateNSView(_ nsView: VisualizerView, context: Context) {
nsView.updateAudioData(audioData)
nsView.setVisualizationMode(mode)
nsView.setReactivity(reactivity)
}
}
#endif

Some files were not shown because too many files have changed in this diff Show More