Compare commits

..

14 Commits

Author SHA1 Message Date
Claude e09983223c Add comprehensive debug logging to SerialManager
- Debug output for connect(), disconnect(), writeData()
- Debug output for formatVUServer() with hex bytes
- Debug output for timer start/stop
- Rate-limited debug output (every 30 frames) to reduce console spam
- Shows connection state changes on main thread
2025-12-14 21:21:54 +00:00
Claude f4b55dbf62 Fix connection status UI not updating properly
- Ensure isConnected is always updated on main thread
- Ensure lastError is always updated on main thread
- Disconnect before reconnecting to avoid stale file descriptors
- Start update timer on main thread
2025-12-14 21:14:21 +00:00
Claude 5e13ff069d Implement VU-Server binary protocol for real hardware
- Add VUServerProtocol struct with proper binary frame format
- Header: '>' + cmd + reserved + data_type + reserved + len_h + len_l + reserved + len_l
- Support commands: setDialPercentSingle (0x03), setDialPercentAll (0x04)
- Support backlight control (RGB/RGBW)
- Add device info queries (firmware/hardware version, UID)
- Add response parsing for '<' responses from hardware
- Show firmware/hardware version in UI when connected
- Update protocol info display to reflect binary protocol
2025-12-14 16:35:31 +00:00
Claude 7a34c719e8 Add animated splash screen with Gnafzgi Software branding
- Create SplashView with animated VU meter icon, wave background
- Show "presented by GNAFZGI SOFTWARE" on app startup
- Auto-dismiss after 2.5 seconds with fade transition
- Bump version to 1.3
2025-12-14 15:46:22 +00:00
Claude 5e0cc74aaf Improve auto-probe UX: auto-connect after finding device
- Auto-probe now automatically connects after finding a VU meter
- Add connection status indicator (green/red dot) on each dial
- Add animation to dial value changes
- Show minimum arc value for better visibility at low values
- Display error messages in hardware panel
- Show "No USB serial devices" message when none found
- Improved status display with port name in green when connected
2025-12-14 15:29:45 +00:00
Claude 0ecf2c7940 Add VU Server for external app control and improve auto-probing
- Add TCP server (VUServer.swift) for external apps to send VU meter values
- Server supports VU Protocol (#channel:value), JSON, and raw bytes
- Configurable options: port, max clients, remote access, broadcast levels
- Add ServerView.swift with full server settings UI and client management
- Improve auto-probing to use two-phase detection (port scan then protocol test)
- Fix termios c_cc tuple access using withUnsafeMutableBytes
- Add network and serial entitlements for server and USB access
- Update version to 1.2.0
2025-12-14 15:23:50 +00: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
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
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
Claude d2dd837f26 Add Paperless Finance Report Tool - Complete implementation
A Python CLI tool for generating financial reports from Paperless-ngx:

- Phase 1 (MVP): Config handling, Paperless API client with auth and
  pagination, custom fields extraction, tag-based summation, CLI output
- Phase 2 (Grouping): Multiple grouping criteria (tag, correspondent,
  category, payment type, month, quarter, year), percentage distribution
- Phase 3 (Reports): HTML reports with Chart.js diagrams (doughnut, bar,
  line charts), PDF export via WeasyPrint, JSON and CSV export
- Phase 4 (Comfort): Automatic tag ID resolution, disk caching with
  diskcache, colorized logging, comprehensive error handling

Features:
- Flexible date filtering (year, month, date range)
- Period comparison with change analysis
- Swiss franc formatting (CHF with apostrophe separators)
- Interactive HTML reports with sortable tables and document links
- Multiple output formats (CLI, HTML, PDF, JSON, CSV)
2025-12-07 10:09:10 +00:00
admin 3134418e6a Merge pull request #1 from metacube2/claude/github-sync-website-017YXsy55JgZ3uUCZx13NfZG
GitHub Sync Website with Apache Server
2025-12-06 10:55:26 +01:00
33 changed files with 10198 additions and 0 deletions
@@ -0,0 +1,374 @@
// !$*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 */; };
A1000024229E3D000000001D /* VUServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000025229E3D000000001E /* VUServer.swift */; };
A1000026229E3D000000001F /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000027229E3D0000000020 /* ServerView.swift */; };
A1000028229E3D0000000021 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000029229E3D0000000022 /* SplashView.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>"; };
A1000025229E3D000000001E /* VUServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VUServer.swift; sourceTree = "<group>"; };
A1000027229E3D0000000020 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = "<group>"; };
A1000029229E3D0000000022 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.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 */,
A1000025229E3D000000001E /* VUServer.swift */,
A1000027229E3D0000000020 /* ServerView.swift */,
A1000029229E3D0000000022 /* SplashView.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 */,
A1000024229E3D000000001D /* VUServer.swift in Sources */,
A1000026229E3D000000001F /* ServerView.swift in Sources */,
A1000028229E3D0000000021 /* SplashView.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.3;
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.3;
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,18 @@
<?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.device.serial</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,92 @@
//
// 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
// Includes VU Server for external app connections
//
import SwiftUI
@main
struct AudioVUMeterApp: App {
@StateObject private var audioEngine = AudioEngine()
@StateObject private var systemMonitor = SystemMonitor()
@StateObject private var serialManager = SerialManager()
@StateObject private var vuServer = VUServer()
// Timer for updating hardware values
@State private var updateTimer: Timer?
// Splash screen state
@State private var showSplash = true
var body: some Scene {
WindowGroup {
ZStack {
ContentView()
.environmentObject(audioEngine)
.environmentObject(systemMonitor)
.environmentObject(serialManager)
.environmentObject(vuServer)
.onAppear {
setupServer()
startHardwareUpdateTimer()
}
.onDisappear {
stopHardwareUpdateTimer()
vuServer.stop()
}
// Splash screen overlay
if showSplash {
SplashView {
withAnimation(.easeOut(duration: 0.5)) {
showSplash = false
}
}
.transition(.opacity)
}
}
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
Settings {
SettingsView()
.environmentObject(audioEngine)
.environmentObject(serialManager)
.environmentObject(vuServer)
}
}
private func setupServer() {
// Link server to serial manager for broadcasting
vuServer.serialManager = serialManager
// Auto-start server if it was enabled
if vuServer.options.enabled {
vuServer.start()
}
}
private func startHardwareUpdateTimer() {
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { _ in
// Check if external control is active from VU Server
if vuServer.externalControlActive, let externalValues = vuServer.receivedDialValues {
// Use values from external app
serialManager.dialValues = externalValues
} else {
// Use local audio/system values
serialManager.updateValues(audioEngine: audioEngine, systemMonitor: systemMonitor)
}
}
}
private func stopHardwareUpdateTimer() {
updateTimer?.invalidate()
updateTimer = nil
}
}
+363
View File
@@ -0,0 +1,363 @@
//
// 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
@EnvironmentObject var vuServer: VUServer
@State private var showSettings = false
@State private var showHardwareSettings = false
@State private var showServerSettings = 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()
// Server settings button
Button(action: { showServerSettings.toggle() }) {
Image(systemName: "server.rack")
.font(.system(size: 14))
.foregroundColor(vuServer.isRunning ? .cyan : .gray)
}
.buttonStyle(.plain)
.help("VU Server Settings")
// 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)
// VU Server Panel
ServerPanelView()
.environmentObject(vuServer)
// 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: 880)
.sheet(isPresented: $showHardwareSettings) {
HardwareSettingsSheet()
.environmentObject(serialManager)
}
.sheet(isPresented: $showServerSettings) {
ServerSettingsSheet()
.environmentObject(vuServer)
}
.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())
.environmentObject(VUServer())
}
@@ -0,0 +1,582 @@
//
// 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 / Errors
if serialManager.isConnected {
VStack(spacing: 4) {
HStack {
Text("TX: \(formatBytes(serialManager.bytesSent))")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
Text("RX: \(formatBytes(serialManager.bytesReceived))")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Text(serialManager.selectedPortPath.components(separatedBy: "/").last ?? "")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.green)
}
// VU-Server hardware info
if serialManager.selectedProtocol == .vuServer {
HStack {
if let fw = serialManager.firmwareVersion {
Text("FW: \(fw)")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.cyan)
}
if let hw = serialManager.hardwareVersion {
Text("HW: \(hw)")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.cyan)
}
Spacer()
}
}
}
} else if let error = serialManager.lastError {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.system(size: 10))
Text(error)
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.red)
.lineLimit(2)
Spacer()
}
} 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)
}
}
} else if serialManager.availablePorts.isEmpty {
HStack {
Image(systemName: "usb")
.foregroundColor(.orange)
.font(.system(size: 10))
Text("No USB serial devices detected")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.orange)
Spacer()
}
}
}
.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 with connection indicator
HStack(spacing: 2) {
Circle()
.fill(isConnected ? Color.green : Color.red)
.frame(width: 5, height: 5)
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.3), lineWidth: 4)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(180))
// Value arc - always show with minimum visibility
Circle()
.trim(from: 0.25, to: 0.25 + max(0.02, (Double(value) / 255.0) * 0.5))
.stroke(
dialColor(for: value, connected: isConnected),
style: StrokeStyle(lineWidth: 4, lineCap: .round)
)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(180))
.animation(.easeOut(duration: 0.1), value: value)
// 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, connected: Bool) -> Color {
if !connected {
return Color.gray.opacity(0.5)
}
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("Binary Protocol: '>' + 9-byte header + payload")
Text("Commands: 0x03 (single %), 0x04 (all %)")
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>
File diff suppressed because it is too large Load Diff
+484
View File
@@ -0,0 +1,484 @@
//
// ServerView.swift
// AudioVUMeter
//
// Server configuration and status view
// Allows enabling/disabling the VU Server for external app connections
//
import SwiftUI
// MARK: - Server Panel in Main View
struct ServerPanelView: View {
@EnvironmentObject var vuServer: VUServer
var body: some View {
VStack(spacing: 12) {
// Header
HStack {
Text("VU SERVER")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
Spacer()
// Status indicator
HStack(spacing: 6) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
Text(statusText)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.foregroundColor(statusColor)
}
}
// Quick info
if vuServer.isRunning {
HStack {
// Port info
Label(":\(vuServer.options.port)", systemImage: "network")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.cyan)
Spacer()
// Client count
Label("\(vuServer.connectedClients.count)", systemImage: "person.2.fill")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(vuServer.connectedClients.isEmpty ? .gray : .green)
Spacer()
// External control indicator
if vuServer.externalControlActive {
Label("EXT", systemImage: "arrow.down.circle.fill")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(.orange)
}
}
}
// Toggle button
Button(action: {
vuServer.toggle()
vuServer.saveOptions()
}) {
HStack {
Image(systemName: vuServer.isRunning ? "stop.fill" : "play.fill")
Text(vuServer.isRunning ? "Stop Server" : "Start Server")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(ServerButtonStyle(isRunning: vuServer.isRunning))
// Last command (if any)
if vuServer.isRunning && !vuServer.lastReceivedCommand.isEmpty {
HStack {
Text("Last:")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.gray)
Text(vuServer.lastReceivedCommand.prefix(30) + (vuServer.lastReceivedCommand.count > 30 ? "..." : ""))
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.cyan)
.lineLimit(1)
Spacer()
}
}
}
.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 vuServer.isRunning {
return vuServer.connectedClients.isEmpty ? .yellow : .green
}
return .gray
}
private var statusText: String {
if vuServer.isRunning {
if vuServer.connectedClients.isEmpty {
return "LISTENING"
}
return "\(vuServer.connectedClients.count) CLIENT\(vuServer.connectedClients.count == 1 ? "" : "S")"
}
return "STOPPED"
}
private var borderColor: Color {
if vuServer.externalControlActive { return .orange.opacity(0.5) }
if vuServer.isRunning { return .cyan.opacity(0.3) }
return .clear
}
}
// MARK: - Server Settings View (Full)
struct ServerSettingsView: View {
@EnvironmentObject var vuServer: VUServer
@State private var portString: String = ""
@State private var showAdvanced = false
var body: some View {
Form {
// Main Server Section
Section("Server Control") {
// Enable/Disable toggle
Toggle(isOn: Binding(
get: { vuServer.isRunning },
set: { newValue in
if newValue {
vuServer.start()
} else {
vuServer.stop()
}
vuServer.saveOptions()
}
)) {
HStack {
Image(systemName: vuServer.isRunning ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash")
.foregroundColor(vuServer.isRunning ? .green : .gray)
Text("Server Enabled")
}
}
// Status
if vuServer.isRunning {
HStack {
Text("Status")
Spacer()
Circle()
.fill(Color.green)
.frame(width: 8, height: 8)
Text("Running on port \(vuServer.options.port)")
.foregroundColor(.secondary)
}
}
// Error display
if let error = vuServer.lastError {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(error)
.foregroundColor(.red)
.font(.caption)
}
}
}
// Connection Settings
Section("Connection Settings") {
// Port
HStack {
Text("Port")
Spacer()
TextField("Port", text: $portString)
.frame(width: 80)
.textFieldStyle(.roundedBorder)
.onAppear {
portString = String(vuServer.options.port)
}
.onChange(of: portString) { newValue in
if let port = UInt16(newValue), port > 0 {
vuServer.options.port = port
vuServer.saveOptions()
}
}
}
// Allow remote connections
Toggle("Allow Remote Connections", isOn: $vuServer.options.allowRemote)
.onChange(of: vuServer.options.allowRemote) { _ in
vuServer.saveOptions()
if vuServer.isRunning {
vuServer.restart()
}
}
if vuServer.options.allowRemote {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Warning: Remote access enabled. Anyone on your network can connect.")
.font(.caption)
.foregroundColor(.orange)
}
}
// Max clients
Stepper("Max Clients: \(vuServer.options.maxClients)", value: $vuServer.options.maxClients, in: 1...20)
.onChange(of: vuServer.options.maxClients) { _ in
vuServer.saveOptions()
}
}
// Protocol Settings
Section("Protocol") {
Picker("Server Protocol", selection: $vuServer.options.serverProtocol) {
ForEach(ServerProtocol.allCases) { proto in
Text(proto.rawValue).tag(proto)
}
}
.onChange(of: vuServer.options.serverProtocol) { _ in
vuServer.saveOptions()
}
// Broadcast settings
Toggle("Broadcast Levels to Clients", isOn: $vuServer.options.broadcastLevels)
.onChange(of: vuServer.options.broadcastLevels) { _ in
vuServer.saveOptions()
}
if vuServer.options.broadcastLevels {
HStack {
Text("Broadcast Rate")
Slider(value: $vuServer.options.broadcastInterval, in: 0.033...1.0)
Text("\(Int(1.0 / vuServer.options.broadcastInterval)) Hz")
.frame(width: 50)
}
.onChange(of: vuServer.options.broadcastInterval) { _ in
vuServer.saveOptions()
}
}
}
// Connected Clients
Section("Connected Clients (\(vuServer.connectedClients.count))") {
if vuServer.connectedClients.isEmpty {
HStack {
Image(systemName: "person.slash")
.foregroundColor(.gray)
Text("No clients connected")
.foregroundColor(.secondary)
}
} else {
ForEach(vuServer.connectedClients) { client in
ClientRowView(client: client, onDisconnect: {
vuServer.disconnectClient(client)
})
}
}
}
// Statistics
if vuServer.isRunning {
Section("Statistics") {
HStack {
Text("Received")
Spacer()
Text(formatBytes(vuServer.totalBytesReceived))
.foregroundColor(.secondary)
.font(.system(.body, design: .monospaced))
}
HStack {
Text("Sent")
Spacer()
Text(formatBytes(vuServer.totalBytesSent))
.foregroundColor(.secondary)
.font(.system(.body, design: .monospaced))
}
if vuServer.externalControlActive {
HStack {
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.orange)
Text("External Control Active")
.foregroundColor(.orange)
Spacer()
Button("Release") {
vuServer.externalControlActive = false
vuServer.receivedDialValues = nil
}
.buttonStyle(.borderless)
}
}
if !vuServer.lastReceivedCommand.isEmpty {
VStack(alignment: .leading) {
Text("Last Command")
Text(vuServer.lastReceivedCommand)
.font(.system(.caption, design: .monospaced))
.foregroundColor(.cyan)
.lineLimit(3)
}
}
}
}
// Protocol Reference
Section("Protocol Reference") {
DisclosureGroup("VU Protocol") {
VStack(alignment: .leading, spacing: 4) {
Text("Send values: #channel:percentage")
Text("Example: #0:75 (dial 0 at 75%)")
Text("Channels: 0-3")
Text("Values: 0-100")
Text("Commands: ?, STATUS, RELEASE")
}
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
DisclosureGroup("JSON Protocol") {
VStack(alignment: .leading, spacing: 4) {
Text("{\"dials\":[v1,v2,v3,v4]}")
Text("or {\"d0\":v,\"d1\":v,...}")
Text("Values: 0-255")
Text("Commands: {\"cmd\":\"status\"}")
}
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
DisclosureGroup("Raw Bytes") {
VStack(alignment: .leading, spacing: 4) {
Text("Frame: [0xAA][D1][D2][D3][D4][0x55]")
Text("Values: 0-255 per byte")
}
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
// Test Connection
Section("Test") {
VStack(alignment: .leading, spacing: 8) {
Text("Test with netcat:")
.font(.caption)
.foregroundColor(.secondary)
Text("echo '#0:50' | nc localhost \(vuServer.options.port)")
.font(.system(size: 10, design: .monospaced))
.padding(8)
.background(Color.black.opacity(0.3))
.cornerRadius(4)
Button("Copy Command") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("echo '#0:50' | nc localhost \(vuServer.options.port)", forType: .string)
}
.buttonStyle(.borderless)
}
}
}
.formStyle(.grouped)
}
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: - Client Row View
struct ClientRowView: View {
let client: ConnectedClient
let onDisconnect: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(client.address)
.font(.system(.body, design: .monospaced))
HStack(spacing: 10) {
Text("RX: \(formatBytes(client.bytesReceived))")
Text("TX: \(formatBytes(client.bytesSent))")
}
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(timeAgo(client.lastActivity))
.font(.caption)
.foregroundColor(.secondary)
Button(action: onDisconnect) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
}
private func formatBytes(_ bytes: UInt64) -> String {
if bytes < 1024 { return "\(bytes)B" }
if bytes < 1024 * 1024 { return String(format: "%.1fK", Double(bytes) / 1024) }
return String(format: "%.1fM", Double(bytes) / (1024 * 1024))
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(-date.timeIntervalSinceNow)
if seconds < 60 { return "\(seconds)s" }
if seconds < 3600 { return "\(seconds / 60)m" }
return "\(seconds / 3600)h"
}
}
// MARK: - Server Settings Sheet
struct ServerSettingsSheet: View {
@EnvironmentObject var vuServer: VUServer
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("VU Server Settings")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
.padding()
.background(Color(nsColor: .windowBackgroundColor))
Divider()
// Settings content
ServerSettingsView()
.environmentObject(vuServer)
}
.frame(width: 500, height: 650)
}
}
// MARK: - Button Style
struct ServerButtonStyle: ButtonStyle {
let isRunning: 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(isRunning ? Color.red.opacity(0.7) : Color.cyan.opacity(0.7))
.opacity(configuration.isPressed ? 0.6 : 1.0)
)
}
}
// MARK: - Preview
#Preview {
ServerSettingsView()
.environmentObject(VUServer())
.frame(width: 500, height: 700)
}
@@ -0,0 +1,155 @@
//
// 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
@EnvironmentObject var vuServer: VUServer
@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")
}
// Server Settings
ServerSettingsView()
.environmentObject(vuServer)
.tabItem {
Label("Server", systemImage: "server.rack")
}
// 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.2.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("USB/Serial VU meter hardware,")
Text("and TCP server for external apps")
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.tabItem {
Label("About", systemImage: "info.circle")
}
}
.frame(width: 500, height: 500)
}
}
#Preview {
SettingsView()
.environmentObject(AudioEngine())
.environmentObject(SerialManager())
.environmentObject(VUServer())
}
+251
View File
@@ -0,0 +1,251 @@
//
// SplashView.swift
// AudioVUMeter
//
// Splash screen shown at app startup
// Presented by Gnafzgi Software
//
import SwiftUI
struct SplashView: View {
@State private var isAnimating = false
@State private var showApp = false
@State private var logoScale: CGFloat = 0.5
@State private var logoOpacity: Double = 0
@State private var textOpacity: Double = 0
@State private var subtitleOpacity: Double = 0
@State private var waveOffset: CGFloat = 0
let onComplete: () -> Void
var body: some View {
ZStack {
// Background gradient
LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.05, green: 0.05, blue: 0.1),
Color(red: 0.1, green: 0.08, blue: 0.15),
Color(red: 0.05, green: 0.05, blue: 0.1)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
// Animated wave background
WaveBackground(offset: waveOffset)
.opacity(0.3)
VStack(spacing: 30) {
Spacer()
// Animated VU Meter Icon
ZStack {
// Glow effect
Circle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
Color.green.opacity(0.4),
Color.clear
]),
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.blur(radius: 20)
.scaleEffect(isAnimating ? 1.2 : 1.0)
// Main icon
Image(systemName: "waveform.circle.fill")
.font(.system(size: 100))
.foregroundStyle(
LinearGradient(
colors: [.green, .cyan, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.shadow(color: .green.opacity(0.5), radius: 20)
}
.scaleEffect(logoScale)
.opacity(logoOpacity)
// App Title
VStack(spacing: 8) {
Text("Audio VU Meter")
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [.white, .gray.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
)
Text("Professional Audio Monitoring")
.font(.system(size: 14, weight: .medium, design: .rounded))
.foregroundColor(.gray)
}
.opacity(textOpacity)
Spacer()
// Presented by
VStack(spacing: 6) {
Text("presented by")
.font(.system(size: 11, weight: .regular, design: .rounded))
.foregroundColor(.gray.opacity(0.6))
.tracking(2)
Text("GNAFZGI SOFTWARE")
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [.cyan, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
.tracking(3)
}
.opacity(subtitleOpacity)
.padding(.bottom, 50)
}
// Version badge
VStack {
Spacer()
HStack {
Spacer()
Text("v1.3")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.gray.opacity(0.5))
.padding(8)
}
}
}
.frame(width: 400, height: 500)
.onAppear {
startAnimations()
}
}
private func startAnimations() {
// Wave animation (continuous)
withAnimation(.linear(duration: 8).repeatForever(autoreverses: false)) {
waveOffset = 1
}
// Logo animation
withAnimation(.spring(response: 0.8, dampingFraction: 0.6).delay(0.2)) {
logoScale = 1.0
logoOpacity = 1.0
}
// Pulse animation
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.5)) {
isAnimating = true
}
// Title animation
withAnimation(.easeOut(duration: 0.8).delay(0.6)) {
textOpacity = 1.0
}
// Subtitle animation
withAnimation(.easeOut(duration: 0.8).delay(1.0)) {
subtitleOpacity = 1.0
}
// Auto-dismiss after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation(.easeOut(duration: 0.3)) {
onComplete()
}
}
}
}
// MARK: - Wave Background
struct WaveBackground: View {
let offset: CGFloat
var body: some View {
GeometryReader { geometry in
ZStack {
// First wave
WavePath(offset: offset, amplitude: 20, frequency: 1.5)
.stroke(
LinearGradient(
colors: [.green.opacity(0.3), .cyan.opacity(0.2)],
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 2
)
// Second wave
WavePath(offset: offset + 0.3, amplitude: 15, frequency: 2)
.stroke(
LinearGradient(
colors: [.blue.opacity(0.2), .purple.opacity(0.2)],
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 1.5
)
// Third wave
WavePath(offset: offset + 0.6, amplitude: 25, frequency: 1)
.stroke(
LinearGradient(
colors: [.cyan.opacity(0.15), .green.opacity(0.1)],
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 1
)
}
}
}
}
// MARK: - Wave Path
struct WavePath: Shape {
var offset: CGFloat
var amplitude: CGFloat
var frequency: CGFloat
var animatableData: CGFloat {
get { offset }
set { offset = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
let midY = rect.midY
path.move(to: CGPoint(x: 0, y: midY))
for x in stride(from: 0, through: rect.width, by: 2) {
let relativeX = x / rect.width
let sine = sin((relativeX + offset) * .pi * 2 * frequency)
let y = midY + sine * amplitude
path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
}
// MARK: - Preview
#Preview {
SplashView {
print("Splash complete")
}
}
@@ -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)
}
+568
View File
@@ -0,0 +1,568 @@
//
// VUServer.swift
// AudioVUMeter
//
// TCP Server for external applications to send VU meter data
// Allows other apps to control the physical VU meters remotely
//
import Foundation
import Network
/// Server protocol for incoming data
enum ServerProtocol: String, CaseIterable, Identifiable {
case vuProtocol = "VU Protocol (#channel:value)"
case json = "JSON ({\"dials\":[...]})"
case rawBytes = "Raw Bytes (binary)"
var id: String { rawValue }
}
/// Connected client info
struct ConnectedClient: Identifiable {
let id: UUID
let address: String
let connectedAt: Date
var lastActivity: Date
var bytesReceived: UInt64
var bytesSent: UInt64
init(address: String) {
self.id = UUID()
self.address = address
self.connectedAt = Date()
self.lastActivity = Date()
self.bytesReceived = 0
self.bytesSent = 0
}
}
/// Server options
struct ServerOptions: Codable {
var enabled: Bool = false
var port: UInt16 = 9876
var allowRemote: Bool = false // Only localhost by default
var requireAuth: Bool = false
var authToken: String = ""
var broadcastLevels: Bool = true // Send current levels to clients
var broadcastInterval: Double = 0.1 // 10 Hz
var maxClients: Int = 5
var protocol_: String = ServerProtocol.vuProtocol.rawValue
var serverProtocol: ServerProtocol {
get { ServerProtocol(rawValue: protocol_) ?? .vuProtocol }
set { protocol_ = newValue.rawValue }
}
}
/// VU Meter Server for external app connections
class VUServer: ObservableObject {
// MARK: - Published Properties
@Published var isRunning = false
@Published var options = ServerOptions()
@Published var connectedClients: [ConnectedClient] = []
@Published var lastError: String?
@Published var totalBytesReceived: UInt64 = 0
@Published var totalBytesSent: UInt64 = 0
@Published var lastReceivedCommand: String = ""
// Received dial values from clients (override local values when set)
@Published var receivedDialValues: [Int]? = nil // nil = use local values
@Published var externalControlActive = false
// MARK: - Private Properties
private var listener: NWListener?
private var connections: [UUID: NWConnection] = [:]
private let queue = DispatchQueue(label: "vu.server", qos: .userInteractive)
private var broadcastTimer: Timer?
// Reference to serial manager for broadcasting
weak var serialManager: SerialManager?
// MARK: - Initialization
init() {
loadOptions()
}
deinit {
stop()
}
// MARK: - Server Control
/// Start the server
func start() {
guard !isRunning else { return }
do {
// Configure parameters
let parameters = NWParameters.tcp
parameters.allowLocalEndpointReuse = true
// Create listener
let port = NWEndpoint.Port(rawValue: options.port)!
listener = try NWListener(using: parameters, on: port)
listener?.stateUpdateHandler = { [weak self] state in
DispatchQueue.main.async {
self?.handleListenerState(state)
}
}
listener?.newConnectionHandler = { [weak self] connection in
self?.handleNewConnection(connection)
}
listener?.start(queue: queue)
DispatchQueue.main.async {
self.isRunning = true
self.lastError = nil
}
// Start broadcast timer if enabled
if options.broadcastLevels {
startBroadcastTimer()
}
print("VU Server started on port \(options.port)")
} catch {
DispatchQueue.main.async {
self.lastError = "Failed to start server: \(error.localizedDescription)"
self.isRunning = false
}
}
}
/// Stop the server
func stop() {
stopBroadcastTimer()
// Close all connections
for (_, connection) in connections {
connection.cancel()
}
connections.removeAll()
// Stop listener
listener?.cancel()
listener = nil
DispatchQueue.main.async {
self.isRunning = false
self.connectedClients.removeAll()
self.externalControlActive = false
self.receivedDialValues = nil
}
print("VU Server stopped")
}
/// Toggle server state
func toggle() {
if isRunning {
stop()
} else {
start()
}
}
/// Restart server (after options change)
func restart() {
if isRunning {
stop()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.start()
}
}
}
// MARK: - Connection Handling
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
print("Server ready on port \(options.port)")
case .failed(let error):
lastError = "Server failed: \(error.localizedDescription)"
isRunning = false
case .cancelled:
isRunning = false
default:
break
}
}
private func handleNewConnection(_ connection: NWConnection) {
let clientID = UUID()
// Check max clients
if connections.count >= options.maxClients {
connection.cancel()
return
}
// Check if remote connections are allowed
if !options.allowRemote {
if let endpoint = connection.currentPath?.remoteEndpoint,
case let .hostPort(host, _) = endpoint {
let hostStr = "\(host)"
if !hostStr.contains("127.0.0.1") && !hostStr.contains("localhost") && !hostStr.contains("::1") {
connection.cancel()
return
}
}
}
connections[clientID] = connection
// Get client address
var address = "Unknown"
if let endpoint = connection.currentPath?.remoteEndpoint,
case let .hostPort(host, port) = endpoint {
address = "\(host):\(port)"
}
let client = ConnectedClient(address: address)
DispatchQueue.main.async {
self.connectedClients.append(client)
}
connection.stateUpdateHandler = { [weak self] state in
self?.handleConnectionState(clientID: clientID, state: state)
}
connection.start(queue: queue)
// Start receiving data
receiveData(clientID: clientID, connection: connection)
// Send welcome message
let welcome = "VU-Server Ready\n"
sendData(welcome.data(using: .utf8)!, to: clientID)
}
private func handleConnectionState(clientID: UUID, state: NWConnection.State) {
switch state {
case .ready:
print("Client connected: \(clientID)")
case .failed(_), .cancelled:
removeClient(clientID: clientID)
default:
break
}
}
private func removeClient(clientID: UUID) {
connections[clientID]?.cancel()
connections.removeValue(forKey: clientID)
DispatchQueue.main.async {
self.connectedClients.removeAll { $0.id == clientID }
// If no more clients, disable external control
if self.connectedClients.isEmpty {
self.externalControlActive = false
self.receivedDialValues = nil
}
}
}
// MARK: - Data Handling
private func receiveData(clientID: UUID, connection: NWConnection) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 1024) { [weak self] data, _, isComplete, error in
guard let self = self else { return }
if let data = data, !data.isEmpty {
DispatchQueue.main.async {
self.totalBytesReceived += UInt64(data.count)
if let index = self.connectedClients.firstIndex(where: { $0.id == clientID }) {
self.connectedClients[index].bytesReceived += UInt64(data.count)
self.connectedClients[index].lastActivity = Date()
}
}
self.processReceivedData(data, from: clientID)
}
if let error = error {
print("Receive error: \(error)")
self.removeClient(clientID: clientID)
return
}
if isComplete {
self.removeClient(clientID: clientID)
return
}
// Continue receiving
self.receiveData(clientID: clientID, connection: connection)
}
}
private func processReceivedData(_ data: Data, from clientID: UUID) {
// Try to parse based on protocol
switch options.serverProtocol {
case .vuProtocol:
parseVUProtocol(data, from: clientID)
case .json:
parseJSON(data, from: clientID)
case .rawBytes:
parseRawBytes(data, from: clientID)
}
}
/// Parse VU Protocol: #channel:value\n
private func parseVUProtocol(_ data: Data, from clientID: UUID) {
guard let string = String(data: data, encoding: .utf8) else { return }
DispatchQueue.main.async {
self.lastReceivedCommand = string.trimmingCharacters(in: .whitespacesAndNewlines)
}
// Parse commands line by line
let lines = string.components(separatedBy: .newlines)
var values = receivedDialValues ?? [0, 0, 0, 0]
var hasUpdate = false
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Handle special commands
if trimmed == "?" || trimmed == "STATUS" {
sendStatus(to: clientID)
continue
}
if trimmed == "RELEASE" {
// Release external control
DispatchQueue.main.async {
self.externalControlActive = false
self.receivedDialValues = nil
}
sendData("OK:RELEASED\n".data(using: .utf8)!, to: clientID)
continue
}
// Parse #channel:value format
if trimmed.hasPrefix("#") {
let parts = trimmed.dropFirst().components(separatedBy: ":")
if parts.count == 2,
let channel = Int(parts[0]),
let value = Int(parts[1]),
channel >= 0 && channel < 4 {
// Value is percentage 0-100, convert to 0-255
let byteValue = (value * 255) / 100
values[channel] = max(0, min(255, byteValue))
hasUpdate = true
}
}
// Also support CH1:value format
if trimmed.hasPrefix("CH") {
let parts = trimmed.dropFirst(2).components(separatedBy: ":")
if parts.count == 2,
let channel = Int(parts[0]),
let value = Int(parts[1]),
channel >= 1 && channel <= 4 {
values[channel - 1] = max(0, min(255, value))
hasUpdate = true
}
}
}
if hasUpdate {
DispatchQueue.main.async {
self.receivedDialValues = values
self.externalControlActive = true
}
}
}
/// Parse JSON: {"dials":[v1,v2,v3,v4]} or {"d0":v,"d1":v,...}
private func parseJSON(_ data: Data, from clientID: UUID) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
DispatchQueue.main.async {
if let str = String(data: data, encoding: .utf8) {
self.lastReceivedCommand = str.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
var values = receivedDialValues ?? [0, 0, 0, 0]
var hasUpdate = false
// Array format
if let dials = json["dials"] as? [Int] {
for (i, v) in dials.prefix(4).enumerated() {
values[i] = max(0, min(255, v))
}
hasUpdate = true
}
// Individual dial format
for i in 0..<4 {
if let v = json["d\(i)"] as? Int {
values[i] = max(0, min(255, v))
hasUpdate = true
}
}
// Command handling
if let cmd = json["cmd"] as? String {
switch cmd {
case "status":
sendStatus(to: clientID)
case "release":
DispatchQueue.main.async {
self.externalControlActive = false
self.receivedDialValues = nil
}
default:
break
}
}
if hasUpdate {
DispatchQueue.main.async {
self.receivedDialValues = values
self.externalControlActive = true
}
}
}
/// Parse raw bytes: [0xAA, d1, d2, d3, d4, 0x55]
private func parseRawBytes(_ data: Data, from clientID: UUID) {
DispatchQueue.main.async {
self.lastReceivedCommand = data.map { String(format: "%02X", $0) }.joined(separator: " ")
}
// Look for frame: 0xAA ... 0x55
var values = receivedDialValues ?? [0, 0, 0, 0]
let bytes = Array(data)
var i = 0
while i < bytes.count {
if bytes[i] == 0xAA && i + 5 < bytes.count && bytes[i + 5] == 0x55 {
// Found valid frame
for j in 0..<4 {
values[j] = Int(bytes[i + 1 + j])
}
DispatchQueue.main.async {
self.receivedDialValues = values
self.externalControlActive = true
}
i += 6
} else {
i += 1
}
}
}
// MARK: - Send Data
private func sendData(_ data: Data, to clientID: UUID) {
guard let connection = connections[clientID] else { return }
connection.send(content: data, completion: .contentProcessed { [weak self] error in
if error == nil {
DispatchQueue.main.async {
self?.totalBytesSent += UInt64(data.count)
if let index = self?.connectedClients.firstIndex(where: { $0.id == clientID }) {
self?.connectedClients[index].bytesSent += UInt64(data.count)
}
}
}
})
}
private func sendStatus(to clientID: UUID) {
guard let sm = serialManager else { return }
let status: [String: Any] = [
"connected": sm.isConnected,
"dials": sm.dialValues,
"port": sm.selectedPortPath,
"external": externalControlActive
]
if let data = try? JSONSerialization.data(withJSONObject: status, options: []),
let string = String(data: data, encoding: .utf8) {
sendData((string + "\n").data(using: .utf8)!, to: clientID)
}
}
/// Broadcast current levels to all clients
func broadcastLevels() {
guard !connections.isEmpty, let sm = serialManager else { return }
let message: String
switch options.serverProtocol {
case .vuProtocol:
message = sm.dialValues.enumerated()
.map { "#\($0.offset):\(($0.element * 100) / 255)" }
.joined(separator: "\n") + "\n"
case .json:
message = "{\"dials\":[\(sm.dialValues.map(String.init).joined(separator: ","))]}\n"
case .rawBytes:
// Don't broadcast for raw bytes
return
}
guard let data = message.data(using: .utf8) else { return }
for clientID in connections.keys {
sendData(data, to: clientID)
}
}
// MARK: - Broadcast Timer
private func startBroadcastTimer() {
stopBroadcastTimer()
DispatchQueue.main.async {
self.broadcastTimer = Timer.scheduledTimer(withTimeInterval: self.options.broadcastInterval, repeats: true) { [weak self] _ in
self?.broadcastLevels()
}
}
}
private func stopBroadcastTimer() {
broadcastTimer?.invalidate()
broadcastTimer = nil
}
// MARK: - Persistence
private func loadOptions() {
if let data = UserDefaults.standard.data(forKey: "VUServerOptions"),
let loaded = try? JSONDecoder().decode(ServerOptions.self, from: data) {
options = loaded
}
}
func saveOptions() {
if let data = try? JSONEncoder().encode(options) {
UserDefaults.standard.set(data, forKey: "VUServerOptions")
}
}
// MARK: - Disconnect Client
func disconnectClient(_ client: ConnectedClient) {
removeClient(clientID: client.id)
}
}
+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.
+41
View File
@@ -0,0 +1,41 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual environment
venv/
env/
.venv/
# Config with secrets
config.yaml
# Cache
.cache/
# Output files
output/*.html
output/*.pdf
output/*.json
output/*.csv
# IDE
.idea/
.vscode/
*.swp
*.swo
# Distribution / packaging
dist/
build/
*.egg-info/
# Testing
.pytest_cache/
.coverage
htmlcov/
# Misc
.DS_Store
*.log
+198
View File
@@ -0,0 +1,198 @@
# Paperless Finance Report
Ein Python-basiertes CLI-Tool, das über die Paperless-ngx REST-API Dokumente abruft, Beträge und Custom Fields extrahiert und daraus Finanzberichte generiert.
## Features
- **Basis-Auswertung**: Summe aller Beträge nach Tags, Kategorien, Korrespondenten
- **Zeiträume**: Filter nach Jahr, Monat oder beliebigem Datumsbereich
- **Gruppierung**: Nach Tag, Kategorie, Korrespondent, Zahlungsart, Monat, Quartal
- **Vergleichsberichte**: Jahresvergleiche mit Veränderungsanalyse
- **Mehrere Ausgabeformate**: CLI, HTML (mit Chart.js Diagrammen), PDF, JSON, CSV
- **Caching**: Optionaler Festplatten-Cache für bessere Performance
- **Flexibel**: Konfigurierbare Custom Field Namen
## Installation
### Voraussetzungen
- Python 3.8+
- Paperless-ngx Installation mit REST-API Zugriff
- API-Token (erstellen unter: Paperless → Einstellungen → Authentifizierungs-Tokens)
### Installation
```bash
# Repository klonen
git clone https://github.com/yourusername/paperless-report.git
cd paperless-report
# Virtuelle Umgebung erstellen
python3 -m venv venv
source venv/bin/activate # Linux/macOS
# oder: venv\Scripts\activate # Windows
# Dependencies installieren
pip install -r requirements.txt
# Optional: Vollinstallation mit PDF-Support
pip install -e ".[full]"
```
### Konfiguration
```bash
# Beispiel-Konfiguration erstellen
cp config.yaml.example config.yaml
# Konfiguration anpassen
nano config.yaml
```
Mindestens erforderlich:
```yaml
paperless:
url: "http://localhost:8000" # Deine Paperless URL
token: "YOUR_API_TOKEN" # API Token
```
Alternativ kann der Token auch als Umgebungsvariable gesetzt werden:
```bash
export PAPERLESS_TOKEN="your_api_token"
```
## Verwendung
### Verbindung testen
```bash
python main.py test
```
### Jahresbericht
```bash
# CLI-Ausgabe
python main.py report --year 2024
# Mit Details
python main.py report --year 2024 --detail
# HTML-Bericht
python main.py report --year 2024 --format html
# PDF-Bericht
python main.py report --year 2024 --format pdf
```
### Mit Filtern
```bash
# Nach Tag filtern
python main.py report --year 2024 --tag rechnung
# Nach Korrespondent filtern
python main.py report --year 2024 --correspondent "Swisscom"
# Nach Monat filtern
python main.py report --year 2024 --month 6
```
### Gruppierung
```bash
# Nach Tag gruppieren (Standard)
python main.py report --year 2024 --group-by tag
# Nach Korrespondent gruppieren
python main.py report --year 2024 --group-by correspondent
# Nach Kategorie und Monat gruppieren
python main.py report --year 2024 --group-by category --group-by month
```
### Jahresvergleich
```bash
# CLI-Vergleich
python main.py compare 2023 2024
# HTML-Vergleichsbericht
python main.py compare 2023 2024 --format html
```
### Weitere Befehle
```bash
# Dokumente auflisten
python main.py list-docs --tag rechnung --limit 50
# Cache löschen
python main.py clear-cache
# Hilfe anzeigen
python main.py --help
python main.py report --help
```
## Custom Fields in Paperless
Für die volle Funktionalität sollten folgende Custom Fields in Paperless angelegt werden:
| Feldname | Typ | Beschreibung |
|-----------------|----------|---------------------------------------|
| `betrag` | Währung | Rechnungsbetrag |
| `rechnungsdatum`| Datum | Datum der Rechnung |
| `kategorie` | Auswahl | Wohnen, Gesundheit, Mobilität, etc. |
| `zahlungsart` | Auswahl | Bar, Einzahlung, LSV, eBill |
Die Feldnamen können in der `config.yaml` angepasst werden.
## Ausgabeformate
### CLI
Einfache tabellarische Ausgabe im Terminal.
### HTML
Interaktiver Bericht mit:
- Zusammenfassungskarten
- Chart.js Diagramme (Doughnut, Bar, Line)
- Sortierbare Tabellen
- Links zu Paperless-Dokumenten
- Export-Button für CSV
### PDF
Druckfertiger PDF-Bericht (benötigt WeasyPrint).
### JSON
Maschinenlesbares Format für weitere Verarbeitung.
### CSV
Excel-kompatibles Format mit BOM für korrekte Umlaute.
## Projektstruktur
```
paperless-report/
├── config.yaml.example # Beispiel-Konfiguration
├── config.py # Konfigurationsmanagement
├── paperless_client.py # API-Client
├── extractor.py # Datenextraktion und -aggregation
├── report_generator.py # Berichtsgenerierung
├── main.py # CLI-Einstiegspunkt
├── templates/
│ └── report.html # HTML-Template
├── output/ # Generierte Berichte
├── requirements.txt
├── setup.py
└── README.md
```
## Lizenz
MIT License
+24
View File
@@ -0,0 +1,24 @@
"""
Paperless Finance Report Tool
Generiert Finanzberichte aus Paperless-ngx Dokumenten.
"""
__version__ = '1.0.0'
__author__ = 'Your Name'
from config import Config, get_config
from paperless_client import PaperlessClient, PaperlessAPIError
from extractor import DocumentExtractor, DataAggregator, FinanceDocument
from report_generator import ReportGenerator
__all__ = [
'Config',
'get_config',
'PaperlessClient',
'PaperlessAPIError',
'DocumentExtractor',
'DataAggregator',
'FinanceDocument',
'ReportGenerator',
]
+269
View File
@@ -0,0 +1,269 @@
"""
Konfigurationsmanagement für das Paperless Finance Report Tool.
Lädt und validiert die YAML-Konfiguration.
"""
import os
import sys
from pathlib import Path
from typing import Any, Optional
import yaml
class ConfigError(Exception):
"""Fehler bei der Konfiguration."""
pass
class Config:
"""Konfigurationsklasse für das Paperless Finance Report Tool."""
DEFAULT_CONFIG = {
'paperless': {
'url': 'http://localhost:8000',
'token': '',
'timeout': 30,
},
'custom_fields': {
'betrag': 'betrag',
'rechnungsdatum': 'rechnungsdatum',
'kategorie': 'kategorie',
'zahlungsart': 'zahlungsart',
'periode': 'periode',
'notiz': 'notiz',
},
'defaults': {
'currency': 'CHF',
'date_field': 'archive_date',
'invoice_tag': 'rechnung',
},
'tags': ['rechnung'],
'categories': [],
'output': {
'format': 'html',
'path': './output',
'filename_pattern': 'finanzbericht_{year}',
},
'cache': {
'enabled': True,
'path': './.cache',
'ttl': 3600,
},
'logging': {
'level': 'INFO',
'file': '',
'colorize': True,
},
}
def __init__(self, config_path: Optional[str] = None):
"""
Initialisiert die Konfiguration.
Args:
config_path: Pfad zur config.yaml. Falls None, wird im aktuellen
Verzeichnis und im Script-Verzeichnis gesucht.
"""
self._config = self.DEFAULT_CONFIG.copy()
self._config_path = self._find_config(config_path)
if self._config_path:
self._load_config()
self._validate_config()
def _find_config(self, config_path: Optional[str]) -> Optional[Path]:
"""Sucht nach der Konfigurationsdatei."""
if config_path:
path = Path(config_path)
if path.exists():
return path
raise ConfigError(f"Konfigurationsdatei nicht gefunden: {config_path}")
# Suchpfade
search_paths = [
Path.cwd() / 'config.yaml',
Path.cwd() / 'config.yml',
Path(__file__).parent / 'config.yaml',
Path(__file__).parent / 'config.yml',
Path.home() / '.config' / 'paperless-report' / 'config.yaml',
]
# Umgebungsvariable prüfen
env_path = os.environ.get('PAPERLESS_REPORT_CONFIG')
if env_path:
search_paths.insert(0, Path(env_path))
for path in search_paths:
if path.exists():
return path
return None
def _load_config(self) -> None:
"""Lädt die Konfiguration aus der YAML-Datei."""
try:
with open(self._config_path, 'r', encoding='utf-8') as f:
user_config = yaml.safe_load(f) or {}
# Rekursives Merge der Konfiguration
self._config = self._deep_merge(self._config, user_config)
except yaml.YAMLError as e:
raise ConfigError(f"Fehler beim Parsen der Konfiguration: {e}")
except IOError as e:
raise ConfigError(f"Fehler beim Lesen der Konfiguration: {e}")
def _deep_merge(self, base: dict, override: dict) -> dict:
"""Führt zwei Dictionaries rekursiv zusammen."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._deep_merge(result[key], value)
else:
result[key] = value
return result
def _validate_config(self) -> None:
"""Validiert die Konfiguration."""
# Paperless URL prüfen
url = self.get('paperless.url', '')
if not url:
raise ConfigError("Paperless URL muss konfiguriert werden")
# Token prüfen (kann auch über Umgebungsvariable kommen)
token = self.get('paperless.token', '') or os.environ.get('PAPERLESS_TOKEN', '')
if not token:
raise ConfigError(
"Paperless API-Token muss konfiguriert werden.\n"
"Setze 'paperless.token' in config.yaml oder die Umgebungsvariable PAPERLESS_TOKEN"
)
# Token aus Umgebungsvariable übernehmen falls nicht in Config
if not self.get('paperless.token'):
self._config['paperless']['token'] = token
def get(self, key: str, default: Any = None) -> Any:
"""
Holt einen Konfigurationswert über Punkt-Notation.
Args:
key: Schlüssel in Punkt-Notation, z.B. 'paperless.url'
default: Standardwert falls Schlüssel nicht existiert
Returns:
Der Konfigurationswert oder der Standardwert
"""
keys = key.split('.')
value = self._config
try:
for k in keys:
value = value[k]
return value
except (KeyError, TypeError):
return default
def __getitem__(self, key: str) -> Any:
"""Ermöglicht Zugriff via config['key']."""
value = self.get(key)
if value is None:
raise KeyError(key)
return value
@property
def paperless_url(self) -> str:
"""Paperless Base-URL."""
url = self.get('paperless.url', '')
return url.rstrip('/')
@property
def paperless_token(self) -> str:
"""Paperless API-Token."""
return self.get('paperless.token', '')
@property
def timeout(self) -> int:
"""Request-Timeout in Sekunden."""
return self.get('paperless.timeout', 30)
@property
def currency(self) -> str:
"""Standardwährung."""
return self.get('defaults.currency', 'CHF')
@property
def date_field(self) -> str:
"""Datumsfeld für Filterung."""
return self.get('defaults.date_field', 'archive_date')
@property
def output_format(self) -> str:
"""Standard-Ausgabeformat."""
return self.get('output.format', 'html')
@property
def output_path(self) -> Path:
"""Ausgabeverzeichnis."""
return Path(self.get('output.path', './output'))
@property
def cache_enabled(self) -> bool:
"""Cache aktiviert."""
return self.get('cache.enabled', True)
@property
def cache_path(self) -> Path:
"""Cache-Verzeichnis."""
return Path(self.get('cache.path', './.cache'))
@property
def cache_ttl(self) -> int:
"""Cache-Gültigkeit in Sekunden."""
return self.get('cache.ttl', 3600)
@property
def log_level(self) -> str:
"""Log-Level."""
return self.get('logging.level', 'INFO')
@property
def custom_field_names(self) -> dict:
"""Mapping der Custom Field Namen."""
return self.get('custom_fields', {})
def get_custom_field_name(self, internal_name: str) -> str:
"""Holt den Paperless-Feldnamen für ein internes Feld."""
return self.get(f'custom_fields.{internal_name}', internal_name)
# Globale Config-Instanz (lazy loading)
_config: Optional[Config] = None
def get_config(config_path: Optional[str] = None) -> Config:
"""
Holt die globale Konfiguration.
Args:
config_path: Optionaler Pfad zur Konfigurationsdatei
Returns:
Config-Instanz
"""
global _config
if _config is None or config_path is not None:
_config = Config(config_path)
return _config
def reset_config() -> None:
"""Setzt die globale Konfiguration zurück (für Tests)."""
global _config
_config = None
+78
View File
@@ -0,0 +1,78 @@
# Paperless Finance Report - Konfiguration
# Kopiere diese Datei nach config.yaml und passe die Werte an
paperless:
# URL deiner Paperless-ngx Installation
url: "http://localhost:8000"
# API-Token (erstellen unter: Einstellungen → Authentifizierungs-Tokens)
token: "YOUR_API_TOKEN_HERE"
# Timeout für API-Anfragen in Sekunden
timeout: 30
# Mapping der Custom Field Namen in Paperless
# Die Namen müssen exakt mit den in Paperless angelegten Feldern übereinstimmen
custom_fields:
betrag: "betrag"
rechnungsdatum: "rechnungsdatum"
kategorie: "kategorie"
zahlungsart: "zahlungsart"
periode: "periode"
notiz: "notiz"
# Standardeinstellungen
defaults:
# Währung für Beträge
currency: "CHF"
# Welches Datumsfeld für Zeitraumfilter verwendet werden soll
# Optionen: "archive_date", "created", "added", oder ein Custom Field Name
date_field: "archive_date"
# Standard-Tag für Rechnungen (Name, nicht ID)
invoice_tag: "rechnung"
# Tag-Namen die automatisch erkannt werden sollen
# Die IDs werden beim ersten Start automatisch ermittelt
tags:
- rechnung
- miete
- krankenkasse
- steuern
- versicherung
- nebenkosten
# Kategorien für Gruppierung (müssen in Paperless als Auswahl-Optionen existieren)
categories:
- Wohnen
- Gesundheit
- Mobilität
- Versicherungen
- Steuern
- Lebensmittel
- Freizeit
- Diverses
# Ausgabe-Einstellungen
output:
# Standard-Format: html, pdf, json, cli
format: "html"
# Verzeichnis für generierte Berichte
path: "./output"
# Dateiname-Muster (Platzhalter: {year}, {month}, {date}, {timestamp})
filename_pattern: "finanzbericht_{year}"
# Cache-Einstellungen
cache:
# Cache aktivieren
enabled: true
# Cache-Verzeichnis
path: "./.cache"
# Cache-Gültigkeit in Sekunden (Standard: 1 Stunde)
ttl: 3600
# Logging-Einstellungen
logging:
# Log-Level: DEBUG, INFO, WARNING, ERROR
level: "INFO"
# Log-Datei (leer = nur Konsole)
file: ""
# Farbige Ausgabe
colorize: true
+592
View File
@@ -0,0 +1,592 @@
"""
Daten-Extraktion und Aggregation für das Paperless Finance Report Tool.
Extrahiert Custom Fields aus Dokumenten und aggregiert die Daten
für verschiedene Gruppierungen.
"""
import logging
import re
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from dateutil.parser import parse as parse_date
from config import Config, get_config
from paperless_client import PaperlessClient
logger = logging.getLogger(__name__)
@dataclass
class FinanceDocument:
"""Ein aufbereitetes Finanzdokument."""
id: int
title: str
archive_date: Optional[datetime] = None
created: Optional[datetime] = None
added: Optional[datetime] = None
# Paperless Metadata
correspondent: Optional[str] = None
correspondent_id: Optional[int] = None
document_type: Optional[str] = None
tags: List[str] = field(default_factory=list)
tag_ids: List[int] = field(default_factory=list)
# Custom Fields
betrag: Optional[Decimal] = None
rechnungsdatum: Optional[datetime] = None
kategorie: Optional[str] = None
zahlungsart: Optional[str] = None
periode: Optional[str] = None
notiz: Optional[str] = None
# URLs
web_url: Optional[str] = None
# Original-Daten
raw_data: Dict = field(default_factory=dict)
@property
def effective_date(self) -> Optional[datetime]:
"""Das effektive Datum (Rechnungsdatum oder Archivdatum)."""
return self.rechnungsdatum or self.archive_date
@property
def year(self) -> Optional[int]:
"""Jahr des effektiven Datums."""
date = self.effective_date
return date.year if date else None
@property
def month(self) -> Optional[int]:
"""Monat des effektiven Datums."""
date = self.effective_date
return date.month if date else None
@property
def month_year(self) -> Optional[str]:
"""Monat/Jahr als String (z.B. '2024-01')."""
date = self.effective_date
return date.strftime('%Y-%m') if date else None
@property
def quarter(self) -> Optional[str]:
"""Quartal als String (z.B. 'Q1 2024')."""
date = self.effective_date
if not date:
return None
q = (date.month - 1) // 3 + 1
return f"Q{q} {date.year}"
class DocumentExtractor:
"""Extrahiert und verarbeitet Dokumente aus Paperless."""
def __init__(self, client: PaperlessClient, config: Optional[Config] = None):
"""
Initialisiert den Extractor.
Args:
client: Paperless API Client
config: Konfiguration
"""
self.client = client
self.config = config or get_config()
self._custom_fields_map: Dict[str, int] = {}
def _build_custom_fields_map(self) -> None:
"""Baut ein Mapping von Feldnamen zu IDs."""
if self._custom_fields_map:
return
fields = self.client.get_custom_fields()
for field_id, field_def in fields.items():
name = field_def['name'].lower()
self._custom_fields_map[name] = field_id
def _parse_decimal(self, value: Any) -> Optional[Decimal]:
"""
Parst einen Wert zu Decimal.
Verarbeitet verschiedene Formate:
- 1234.56
- 1234,56
- 1'234.56 (Schweizer Format)
- CHF 1234.56
"""
if value is None:
return None
if isinstance(value, (int, float)):
return Decimal(str(value))
if isinstance(value, Decimal):
return value
if not isinstance(value, str):
return None
# String bereinigen
value = value.strip()
# Währungssymbole entfernen
value = re.sub(r'^(CHF|EUR|USD|Fr\.?)\s*', '', value, flags=re.IGNORECASE)
value = re.sub(r'\s*(CHF|EUR|USD|Fr\.?)$', '', value, flags=re.IGNORECASE)
# Tausender-Trennzeichen entfernen (Apostroph, Punkt als Tausender)
# Schweizer Format: 1'234.56 oder 1'234,56
if "'" in value:
value = value.replace("'", "")
# Deutsches/Schweizer Format mit Punkt als Tausender: 1.234,56
if re.match(r'^\d{1,3}(\.\d{3})+,\d{2}$', value):
value = value.replace(".", "").replace(",", ".")
# Komma als Dezimaltrennzeichen ohne Tausender
elif "," in value and "." not in value:
value = value.replace(",", ".")
try:
return Decimal(value)
except InvalidOperation:
logger.warning(f"Konnte Betrag nicht parsen: {value}")
return None
def _parse_date(self, value: Any) -> Optional[datetime]:
"""Parst einen Wert zu datetime."""
if value is None:
return None
if isinstance(value, datetime):
return value
if not isinstance(value, str):
return None
try:
return parse_date(value)
except (ValueError, TypeError):
logger.warning(f"Konnte Datum nicht parsen: {value}")
return None
def _get_custom_field_value(self, doc: dict, field_name: str) -> Any:
"""Holt den Wert eines Custom Fields aus einem Dokument."""
# Aus resolved fields
resolved = doc.get('custom_fields_resolved', {})
if field_name in resolved:
return resolved[field_name].get('value')
# Aus rohen custom_fields
self._build_custom_fields_map()
field_name_lower = field_name.lower()
for cf in doc.get('custom_fields', []):
field_id = cf.get('field')
# Prüfen ob ID zum gesuchten Feldnamen passt
for name, fid in self._custom_fields_map.items():
if fid == field_id and name == field_name_lower:
return cf.get('value')
return None
def extract_document(self, raw_doc: dict) -> FinanceDocument:
"""
Extrahiert ein aufbereitetes FinanceDocument aus den Rohdaten.
Args:
raw_doc: Rohes Dokument-Dictionary von der API
Returns:
FinanceDocument-Instanz
"""
# Custom Field Namen aus Config
cf_names = self.config.custom_field_names
# Basis-Daten
doc = FinanceDocument(
id=raw_doc['id'],
title=raw_doc.get('title', ''),
raw_data=raw_doc
)
# Datums-Felder
doc.archive_date = self._parse_date(raw_doc.get('archive_date'))
doc.created = self._parse_date(raw_doc.get('created'))
doc.added = self._parse_date(raw_doc.get('added'))
# Korrespondent
doc.correspondent_id = raw_doc.get('correspondent')
doc.correspondent = raw_doc.get('correspondent_name', '')
# Dokumenttyp
doc.document_type = raw_doc.get('document_type_name', '')
# Tags
doc.tag_ids = raw_doc.get('tags', [])
doc.tags = raw_doc.get('tag_names', [])
# URL
doc.web_url = raw_doc.get('web_url', '')
# Custom Fields
betrag_name = cf_names.get('betrag', 'betrag')
doc.betrag = self._parse_decimal(
self._get_custom_field_value(raw_doc, betrag_name)
)
datum_name = cf_names.get('rechnungsdatum', 'rechnungsdatum')
doc.rechnungsdatum = self._parse_date(
self._get_custom_field_value(raw_doc, datum_name)
)
kat_name = cf_names.get('kategorie', 'kategorie')
doc.kategorie = self._get_custom_field_value(raw_doc, kat_name)
zahl_name = cf_names.get('zahlungsart', 'zahlungsart')
doc.zahlungsart = self._get_custom_field_value(raw_doc, zahl_name)
periode_name = cf_names.get('periode', 'periode')
doc.periode = self._get_custom_field_value(raw_doc, periode_name)
notiz_name = cf_names.get('notiz', 'notiz')
doc.notiz = self._get_custom_field_value(raw_doc, notiz_name)
return doc
def extract_documents(self, raw_docs: List[dict]) -> List[FinanceDocument]:
"""
Extrahiert mehrere Dokumente.
Args:
raw_docs: Liste von Roh-Dokumenten
Returns:
Liste von FinanceDocument-Instanzen
"""
# Metadaten auflösen
resolved = self.client.resolve_all_metadata(raw_docs)
return [self.extract_document(doc) for doc in resolved]
@dataclass
class AggregationResult:
"""Ergebnis einer Aggregation."""
# Basis-Statistiken
total_amount: Decimal = Decimal('0')
document_count: int = 0
documents_with_amount: int = 0
documents_without_amount: int = 0
# Dokumente
documents: List[FinanceDocument] = field(default_factory=list)
# Gruppierte Daten
by_tag: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_correspondent: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_category: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_payment_type: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_month: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_quarter: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_year: Dict[int, 'GroupStats'] = field(default_factory=dict)
# Zusätzliche Statistiken
average_amount: Decimal = Decimal('0')
median_amount: Decimal = Decimal('0')
min_amount: Decimal = Decimal('0')
max_amount: Decimal = Decimal('0')
top_items: List[FinanceDocument] = field(default_factory=list)
@property
def total_formatted(self) -> str:
"""Formatierte Gesamtsumme."""
return f"{self.total_amount:,.2f}".replace(',', "'")
@dataclass
class GroupStats:
"""Statistiken für eine Gruppe."""
name: str
amount: Decimal = Decimal('0')
count: int = 0
percentage: float = 0.0
documents: List[FinanceDocument] = field(default_factory=list)
@property
def amount_formatted(self) -> str:
"""Formatierter Betrag."""
return f"{self.amount:,.2f}".replace(',', "'")
class DataAggregator:
"""Aggregiert Finanzdokumente nach verschiedenen Kriterien."""
def __init__(self, config: Optional[Config] = None):
"""
Initialisiert den Aggregator.
Args:
config: Konfiguration
"""
self.config = config or get_config()
def aggregate(
self,
documents: List[FinanceDocument],
group_by: Optional[List[str]] = None
) -> AggregationResult:
"""
Aggregiert Dokumente.
Args:
documents: Liste von Dokumenten
group_by: Liste von Gruppierungskriterien:
'tag', 'correspondent', 'category', 'payment_type',
'month', 'quarter', 'year'
Returns:
AggregationResult mit allen Statistiken
"""
result = AggregationResult()
result.documents = documents
result.document_count = len(documents)
# Beträge sammeln
amounts: List[Decimal] = []
for doc in documents:
if doc.betrag is not None:
result.total_amount += doc.betrag
result.documents_with_amount += 1
amounts.append(doc.betrag)
else:
result.documents_without_amount += 1
# Basis-Statistiken
if amounts:
amounts_sorted = sorted(amounts)
result.min_amount = amounts_sorted[0]
result.max_amount = amounts_sorted[-1]
result.average_amount = result.total_amount / len(amounts)
# Median
mid = len(amounts_sorted) // 2
if len(amounts_sorted) % 2 == 0:
result.median_amount = (amounts_sorted[mid - 1] + amounts_sorted[mid]) / 2
else:
result.median_amount = amounts_sorted[mid]
# Top-Posten
docs_with_amount = [d for d in documents if d.betrag is not None]
result.top_items = sorted(
docs_with_amount,
key=lambda d: d.betrag or Decimal('0'),
reverse=True
)[:10]
# Gruppierungen
group_by = group_by or ['tag', 'correspondent', 'category', 'month']
if 'tag' in group_by:
result.by_tag = self._group_by_tags(documents, result.total_amount)
if 'correspondent' in group_by:
result.by_correspondent = self._group_by_field(
documents, 'correspondent', result.total_amount
)
if 'category' in group_by:
result.by_category = self._group_by_field(
documents, 'kategorie', result.total_amount
)
if 'payment_type' in group_by:
result.by_payment_type = self._group_by_field(
documents, 'zahlungsart', result.total_amount
)
if 'month' in group_by:
result.by_month = self._group_by_field(
documents, 'month_year', result.total_amount
)
if 'quarter' in group_by:
result.by_quarter = self._group_by_field(
documents, 'quarter', result.total_amount
)
if 'year' in group_by:
result.by_year = self._group_by_field(
documents, 'year', result.total_amount
)
return result
def _group_by_tags(
self,
documents: List[FinanceDocument],
total: Decimal
) -> Dict[str, GroupStats]:
"""Gruppiert nach Tags (ein Dokument kann mehrere Tags haben)."""
groups: Dict[str, GroupStats] = {}
for doc in documents:
if not doc.tags:
tag_name = 'Ohne Tag'
if tag_name not in groups:
groups[tag_name] = GroupStats(name=tag_name)
groups[tag_name].count += 1
if doc.betrag:
groups[tag_name].amount += doc.betrag
groups[tag_name].documents.append(doc)
else:
for tag in doc.tags:
if tag not in groups:
groups[tag] = GroupStats(name=tag)
groups[tag].count += 1
if doc.betrag:
groups[tag].amount += doc.betrag
groups[tag].documents.append(doc)
# Prozente berechnen
if total > 0:
for stats in groups.values():
stats.percentage = float(stats.amount / total * 100)
# Nach Betrag sortieren
return dict(sorted(
groups.items(),
key=lambda x: x[1].amount,
reverse=True
))
def _group_by_field(
self,
documents: List[FinanceDocument],
field: str,
total: Decimal
) -> Dict[str, GroupStats]:
"""Gruppiert nach einem einzelnen Feld."""
groups: Dict[str, GroupStats] = {}
for doc in documents:
value = getattr(doc, field, None)
if value is None or value == '':
key = 'Nicht zugeordnet'
else:
key = str(value)
if key not in groups:
groups[key] = GroupStats(name=key)
groups[key].count += 1
if doc.betrag:
groups[key].amount += doc.betrag
groups[key].documents.append(doc)
# Prozente berechnen
if total > 0:
for stats in groups.values():
stats.percentage = float(stats.amount / total * 100)
# Nach Betrag sortieren (bei Monaten chronologisch)
if field in ('month_year', 'quarter'):
return dict(sorted(groups.items()))
else:
return dict(sorted(
groups.items(),
key=lambda x: x[1].amount,
reverse=True
))
def compare_periods(
self,
documents: List[FinanceDocument],
period1: Union[int, str],
period2: Union[int, str],
period_type: str = 'year'
) -> Dict[str, Any]:
"""
Vergleicht zwei Zeiträume.
Args:
documents: Alle Dokumente
period1: Erste Periode (z.B. 2023)
period2: Zweite Periode (z.B. 2024)
period_type: 'year', 'quarter', 'month'
Returns:
Vergleichsergebnis
"""
# Dokumente nach Periode filtern
def get_period(doc: FinanceDocument) -> Optional[Union[int, str]]:
if period_type == 'year':
return doc.year
elif period_type == 'quarter':
return doc.quarter
elif period_type == 'month':
return doc.month_year
return None
docs1 = [d for d in documents if get_period(d) == period1]
docs2 = [d for d in documents if get_period(d) == period2]
agg1 = self.aggregate(docs1, ['tag', 'category'])
agg2 = self.aggregate(docs2, ['tag', 'category'])
# Differenzen berechnen
diff_absolute = agg2.total_amount - agg1.total_amount
diff_percent = (
float(diff_absolute / agg1.total_amount * 100)
if agg1.total_amount > 0 else 0
)
# Kategorien vergleichen
category_comparison = {}
all_categories = set(agg1.by_category.keys()) | set(agg2.by_category.keys())
for cat in all_categories:
stats1 = agg1.by_category.get(cat, GroupStats(name=cat))
stats2 = agg2.by_category.get(cat, GroupStats(name=cat))
diff = stats2.amount - stats1.amount
pct_change = (
float(diff / stats1.amount * 100)
if stats1.amount > 0 else (100.0 if stats2.amount > 0 else 0)
)
category_comparison[cat] = {
'period1': stats1.amount,
'period2': stats2.amount,
'diff_absolute': diff,
'diff_percent': pct_change,
'status': 'new' if stats1.amount == 0 else (
'removed' if stats2.amount == 0 else 'changed'
)
}
return {
'period1': {
'name': str(period1),
'total': agg1.total_amount,
'count': agg1.document_count,
'aggregation': agg1,
},
'period2': {
'name': str(period2),
'total': agg2.total_amount,
'count': agg2.document_count,
'aggregation': agg2,
},
'diff_absolute': diff_absolute,
'diff_percent': diff_percent,
'category_comparison': category_comparison,
}
+489
View File
@@ -0,0 +1,489 @@
#!/usr/bin/env python3
"""
Paperless Finance Report Tool
CLI-Einstiegspunkt für das Paperless Finanz-Auswertungstool.
Generiert Finanzberichte aus Paperless-ngx Dokumenten.
"""
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import click
from tabulate import tabulate
# Lokale Imports
from config import Config, ConfigError, get_config, reset_config
from extractor import DataAggregator, DocumentExtractor
from paperless_client import PaperlessAPIError, PaperlessClient
from report_generator import ReportGenerator
# Logger einrichten
logger = logging.getLogger('paperless_report')
def setup_logging(level: str = 'INFO', colorize: bool = True) -> None:
"""Richtet das Logging ein."""
log_level = getattr(logging, level.upper(), logging.INFO)
if colorize:
try:
import colorlog
handler = colorlog.StreamHandler()
handler.setFormatter(colorlog.ColoredFormatter(
'%(log_color)s%(levelname)-8s%(reset)s %(message)s',
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'red,bg_white',
}
))
except ImportError:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(levelname)-8s %(message)s'))
else:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(levelname)-8s %(message)s'))
logger.addHandler(handler)
logger.setLevel(log_level)
# Auch für andere Module
logging.getLogger('paperless_report').setLevel(log_level)
def get_cache(config: Config):
"""Erstellt den Cache falls aktiviert."""
if not config.cache_enabled:
return None
try:
from diskcache import Cache
cache_path = config.cache_path
cache_path.mkdir(parents=True, exist_ok=True)
return Cache(str(cache_path))
except ImportError:
logger.warning("diskcache nicht installiert, Cache deaktiviert")
return None
# CLI-Gruppe
@click.group()
@click.option('--config', '-c', 'config_path', type=click.Path(exists=True),
help='Pfad zur Konfigurationsdatei')
@click.option('--verbose', '-v', is_flag=True, help='Ausführliche Ausgabe')
@click.option('--quiet', '-q', is_flag=True, help='Nur Fehler ausgeben')
@click.pass_context
def cli(ctx, config_path: Optional[str], verbose: bool, quiet: bool):
"""
Paperless Finance Report Tool
Generiert Finanzberichte aus Paperless-ngx Dokumenten.
Beispiele:
# Jahresbericht 2024
paperless-report report --year 2024
# Mit Tag-Filter
paperless-report report --year 2024 --tag rechnung
# Jahresvergleich
paperless-report compare 2023 2024
# Verbindung testen
paperless-report test
"""
ctx.ensure_object(dict)
# Log-Level bestimmen
if quiet:
log_level = 'ERROR'
elif verbose:
log_level = 'DEBUG'
else:
log_level = 'INFO'
setup_logging(log_level)
# Config laden
try:
reset_config()
config = get_config(config_path)
ctx.obj['config'] = config
except ConfigError as e:
click.echo(f"Konfigurationsfehler: {e}", err=True)
sys.exit(1)
@cli.command()
@click.pass_context
def test(ctx):
"""Testet die Verbindung zur Paperless-API."""
config = ctx.obj['config']
click.echo(f"Teste Verbindung zu {config.paperless_url}...")
try:
cache = get_cache(config)
client = PaperlessClient(config, cache)
if client.test_connection():
click.echo(click.style("Verbindung erfolgreich!", fg='green'))
# Statistiken anzeigen
click.echo("\nStatistiken:")
tags = client.get_tags()
correspondents = client.get_correspondents()
custom_fields = client.get_custom_fields()
click.echo(f" Tags: {len(tags)}")
click.echo(f" Korrespondenten: {len(correspondents)}")
click.echo(f" Custom Fields: {len(custom_fields)}")
# Custom Fields auflisten
if custom_fields:
click.echo("\nCustom Fields:")
for field_id, field in custom_fields.items():
click.echo(f" - {field['name']} (Typ: {field.get('data_type', 'unknown')})")
else:
click.echo(click.style("Verbindung fehlgeschlagen!", fg='red'))
sys.exit(1)
except PaperlessAPIError as e:
click.echo(click.style(f"API-Fehler: {e}", fg='red'), err=True)
sys.exit(1)
@cli.command()
@click.option('--year', '-y', type=int, help='Jahr für den Bericht')
@click.option('--month', '-m', type=int, help='Monat (1-12)')
@click.option('--tag', '-t', 'tags', multiple=True, help='Nach Tag filtern (mehrfach möglich)')
@click.option('--correspondent', help='Nach Korrespondent filtern')
@click.option('--group-by', '-g', 'group_by',
type=click.Choice(['tag', 'correspondent', 'category', 'payment_type', 'month', 'quarter', 'year']),
multiple=True, default=['tag', 'category', 'month'],
help='Gruppierung (mehrfach möglich)')
@click.option('--format', '-f', 'output_format',
type=click.Choice(['cli', 'html', 'pdf', 'json', 'csv']),
default='cli', help='Ausgabeformat')
@click.option('--output', '-o', 'output_file', type=click.Path(),
help='Ausgabedatei (optional)')
@click.option('--detail', '-d', is_flag=True, help='Detaillierte Ausgabe')
@click.option('--no-cache', is_flag=True, help='Cache ignorieren')
@click.pass_context
def report(ctx, year: Optional[int], month: Optional[int], tags: tuple,
correspondent: Optional[str], group_by: tuple, output_format: str,
output_file: Optional[str], detail: bool, no_cache: bool):
"""
Generiert einen Finanzbericht.
Beispiele:
# Jahresbericht 2024 als CLI
paperless-report report --year 2024
# HTML-Bericht mit Tag-Filter
paperless-report report --year 2024 --tag rechnung --format html
# Detaillierter Bericht nach Korrespondent gruppiert
paperless-report report --year 2024 --group-by correspondent --detail
# PDF für einen bestimmten Monat
paperless-report report --year 2024 --month 6 --format pdf
"""
config = ctx.obj['config']
# Standard: aktuelles Jahr
if not year:
year = datetime.now().year
click.echo(f"Kein Jahr angegeben, verwende {year}")
try:
cache = None if no_cache else get_cache(config)
client = PaperlessClient(config, cache)
extractor = DocumentExtractor(client, config)
aggregator = DataAggregator(config)
generator = ReportGenerator(config)
# Dokumente abrufen
click.echo(f"Lade Dokumente für {year}" + (f"/{month}" if month else "") + "...")
with click.progressbar(length=1, label='API-Abfrage') as bar:
raw_docs = client.get_documents(
tags=list(tags) if tags else None,
correspondent=correspondent,
year=year,
month=month,
)
bar.update(1)
if not raw_docs:
click.echo(click.style("Keine Dokumente gefunden.", fg='yellow'))
return
click.echo(f"Gefunden: {len(raw_docs)} Dokumente")
# Dokumente extrahieren
click.echo("Extrahiere Daten...")
documents = extractor.extract_documents(raw_docs)
# Aggregieren
click.echo("Aggregiere Daten...")
result = aggregator.aggregate(documents, list(group_by))
# Titel generieren
if month:
title = f"Paperless Finanzbericht {month:02d}/{year}"
else:
title = f"Paperless Finanzbericht {year}"
# Ausgabe
if output_format == 'cli':
output = generator.generate_cli(result, title, detail)
click.echo()
click.echo(output)
elif output_format == 'html':
if output_file:
path = Path(output_file)
else:
path = generator.save_html(result, title, year, month)
click.echo(click.style(f"HTML-Bericht gespeichert: {path}", fg='green'))
# Bericht öffnen?
if click.confirm("Bericht im Browser öffnen?", default=True):
import webbrowser
webbrowser.open(f"file://{path.absolute()}")
elif output_format == 'pdf':
if output_file:
path = Path(output_file)
pdf_bytes = generator.generate_pdf(result, title, year, month)
with open(path, 'wb') as f:
f.write(pdf_bytes)
else:
path = generator.save_pdf(result, title, year, month)
click.echo(click.style(f"PDF-Bericht gespeichert: {path}", fg='green'))
elif output_format == 'json':
if output_file:
path = Path(output_file)
json_str = generator.generate_json(result)
with open(path, 'w', encoding='utf-8') as f:
f.write(json_str)
else:
path = generator.save_json(result, year, month)
click.echo(click.style(f"JSON-Export gespeichert: {path}", fg='green'))
elif output_format == 'csv':
if output_file:
path = Path(output_file)
csv_str = generator.generate_csv(documents)
with open(path, 'w', encoding='utf-8-sig') as f:
f.write(csv_str)
else:
path = generator.save_csv(documents, year, month)
click.echo(click.style(f"CSV-Export gespeichert: {path}", fg='green'))
except PaperlessAPIError as e:
click.echo(click.style(f"API-Fehler: {e}", fg='red'), err=True)
sys.exit(1)
except Exception as e:
logger.exception("Unerwarteter Fehler")
click.echo(click.style(f"Fehler: {e}", fg='red'), err=True)
sys.exit(1)
@cli.command()
@click.argument('period1', type=int)
@click.argument('period2', type=int)
@click.option('--tag', '-t', 'tags', multiple=True, help='Nach Tag filtern')
@click.option('--format', '-f', 'output_format',
type=click.Choice(['cli', 'html']), default='cli',
help='Ausgabeformat')
@click.option('--output', '-o', 'output_file', type=click.Path(),
help='Ausgabedatei')
@click.pass_context
def compare(ctx, period1: int, period2: int, tags: tuple,
output_format: str, output_file: Optional[str]):
"""
Vergleicht zwei Zeiträume (Jahre).
Beispiele:
# Jahresvergleich 2023 vs 2024
paperless-report compare 2023 2024
# Mit Tag-Filter
paperless-report compare 2023 2024 --tag rechnung
# Als HTML
paperless-report compare 2023 2024 --format html
"""
config = ctx.obj['config']
try:
cache = get_cache(config)
client = PaperlessClient(config, cache)
extractor = DocumentExtractor(client, config)
aggregator = DataAggregator(config)
generator = ReportGenerator(config)
# Dokumente für beide Perioden laden
click.echo(f"Lade Dokumente für {period1} und {period2}...")
raw_docs_1 = client.get_documents(
tags=list(tags) if tags else None,
year=period1
)
raw_docs_2 = client.get_documents(
tags=list(tags) if tags else None,
year=period2
)
click.echo(f"Gefunden: {len(raw_docs_1)} ({period1}) / {len(raw_docs_2)} ({period2})")
# Dokumente zusammenführen und extrahieren
all_raw_docs = raw_docs_1 + raw_docs_2
all_docs = extractor.extract_documents(all_raw_docs)
# Vergleich
click.echo("Vergleiche Perioden...")
comparison = aggregator.compare_periods(all_docs, period1, period2)
if output_format == 'cli':
output = generator.generate_comparison_cli(comparison)
click.echo()
click.echo(output)
elif output_format == 'html':
# Aggregation für das neuere Jahr als Basis
docs_2 = [d for d in all_docs if d.year == period2]
result = aggregator.aggregate(docs_2, ['tag', 'category', 'month'])
title = f"Vergleich {period1} vs {period2}"
if output_file:
path = Path(output_file)
html = generator.generate_html(result, title, period2, comparison=comparison)
with open(path, 'w', encoding='utf-8') as f:
f.write(html)
else:
path = generator.save_html(result, title, period2, comparison=comparison)
click.echo(click.style(f"Vergleichsbericht gespeichert: {path}", fg='green'))
except PaperlessAPIError as e:
click.echo(click.style(f"API-Fehler: {e}", fg='red'), err=True)
sys.exit(1)
@cli.command()
@click.option('--tag', '-t', 'tags', multiple=True, help='Nach Tag filtern')
@click.option('--year', '-y', type=int, help='Jahr')
@click.option('--limit', '-l', type=int, default=20, help='Anzahl Dokumente')
@click.pass_context
def list_docs(ctx, tags: tuple, year: Optional[int], limit: int):
"""
Listet Dokumente auf.
Beispiele:
# Letzte 20 Dokumente
paperless-report list-docs
# Mit Tag-Filter
paperless-report list-docs --tag rechnung --limit 50
"""
config = ctx.obj['config']
try:
cache = get_cache(config)
client = PaperlessClient(config, cache)
extractor = DocumentExtractor(client, config)
raw_docs = client.get_documents(
tags=list(tags) if tags else None,
year=year
)
if not raw_docs:
click.echo("Keine Dokumente gefunden.")
return
documents = extractor.extract_documents(raw_docs[:limit])
# Tabelle erstellen
table_data = []
for doc in documents:
table_data.append([
doc.id,
(doc.effective_date.strftime('%d.%m.%Y')
if doc.effective_date else '-'),
doc.title[:40] + ('...' if len(doc.title) > 40 else ''),
doc.correspondent[:20] if doc.correspondent else '-',
(f"{config.currency} {doc.betrag:,.2f}".replace(',', "'")
if doc.betrag else '-'),
])
headers = ['ID', 'Datum', 'Titel', 'Korrespondent', 'Betrag']
click.echo(tabulate(table_data, headers=headers, tablefmt='simple'))
click.echo(f"\nGesamt: {len(raw_docs)} Dokumente (zeige {min(limit, len(raw_docs))})")
except PaperlessAPIError as e:
click.echo(click.style(f"API-Fehler: {e}", fg='red'), err=True)
sys.exit(1)
@cli.command()
@click.pass_context
def clear_cache(ctx):
"""Löscht den Cache."""
config = ctx.obj['config']
cache_path = config.cache_path
if cache_path.exists():
import shutil
shutil.rmtree(cache_path)
click.echo(click.style("Cache gelöscht.", fg='green'))
else:
click.echo("Kein Cache vorhanden.")
@cli.command()
@click.pass_context
def init(ctx):
"""Erstellt eine Beispiel-Konfigurationsdatei."""
config_file = Path.cwd() / 'config.yaml'
if config_file.exists():
if not click.confirm(f"{config_file} existiert bereits. Überschreiben?"):
return
# Beispiel-Config kopieren
example_config = Path(__file__).parent / 'config.yaml.example'
if example_config.exists():
import shutil
shutil.copy(example_config, config_file)
click.echo(click.style(f"Konfiguration erstellt: {config_file}", fg='green'))
click.echo("\nBitte bearbeite die Datei und setze:")
click.echo(" - paperless.url: URL deiner Paperless-Installation")
click.echo(" - paperless.token: API-Token")
else:
click.echo(click.style("Beispiel-Konfiguration nicht gefunden.", fg='red'))
def main():
"""Haupteinstiegspunkt."""
cli(obj={})
if __name__ == '__main__':
main()
+1
View File
@@ -0,0 +1 @@
# Dieses Verzeichnis enthält generierte Berichte
+537
View File
@@ -0,0 +1,537 @@
"""
Paperless-ngx API Client.
Handhabt die Kommunikation mit der Paperless REST-API inkl. Paginierung und Caching.
"""
import hashlib
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Generator, List, Optional, Union
from urllib.parse import urlencode, urljoin
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from config import Config, get_config
logger = logging.getLogger(__name__)
class PaperlessAPIError(Exception):
"""Fehler bei der API-Kommunikation."""
def __init__(self, message: str, status_code: Optional[int] = None, response: Optional[dict] = None):
super().__init__(message)
self.status_code = status_code
self.response = response
class PaperlessClient:
"""Client für die Paperless-ngx REST-API."""
# API-Endpunkte
ENDPOINTS = {
'documents': '/api/documents/',
'tags': '/api/tags/',
'correspondents': '/api/correspondents/',
'document_types': '/api/document_types/',
'custom_fields': '/api/custom_fields/',
'storage_paths': '/api/storage_paths/',
}
def __init__(self, config: Optional[Config] = None, cache: Optional[Any] = None):
"""
Initialisiert den API-Client.
Args:
config: Konfigurationsobjekt. Falls None, wird globale Config verwendet.
cache: Optionales Cache-Objekt (diskcache.Cache)
"""
self.config = config or get_config()
self.base_url = self.config.paperless_url
self.token = self.config.paperless_token
self.timeout = self.config.timeout
self.cache = cache
# Session mit Retry-Logik erstellen
self.session = self._create_session()
# Cached Metadata
self._custom_fields_cache: Optional[Dict[int, dict]] = None
self._tags_cache: Optional[Dict[int, dict]] = None
self._correspondents_cache: Optional[Dict[int, dict]] = None
self._document_types_cache: Optional[Dict[int, dict]] = None
def _create_session(self) -> requests.Session:
"""Erstellt eine Session mit Retry-Konfiguration."""
session = requests.Session()
# Retry-Strategie
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount('http://', adapter)
session.mount('https://', adapter)
# Standard-Header
session.headers.update({
'Authorization': f'Token {self.token}',
'Accept': 'application/json',
'Content-Type': 'application/json',
})
return session
def _get_cache_key(self, endpoint: str, params: Optional[dict] = None) -> str:
"""Generiert einen Cache-Schlüssel."""
key_data = f"{self.base_url}{endpoint}"
if params:
key_data += json.dumps(params, sort_keys=True)
return hashlib.md5(key_data.encode()).hexdigest()
def _request(
self,
method: str,
endpoint: str,
params: Optional[dict] = None,
data: Optional[dict] = None,
use_cache: bool = True
) -> dict:
"""
Führt einen API-Request durch.
Args:
method: HTTP-Methode (GET, POST, etc.)
endpoint: API-Endpunkt (relativ zur Base-URL)
params: Query-Parameter
data: Request-Body
use_cache: Cache verwenden (nur für GET)
Returns:
API-Response als Dictionary
"""
url = urljoin(self.base_url, endpoint)
# Cache prüfen (nur GET-Requests)
if method.upper() == 'GET' and use_cache and self.cache:
cache_key = self._get_cache_key(endpoint, params)
cached = self.cache.get(cache_key)
if cached is not None:
logger.debug(f"Cache hit für {endpoint}")
return cached
logger.debug(f"API Request: {method} {url} params={params}")
try:
response = self.session.request(
method=method,
url=url,
params=params,
json=data,
timeout=self.timeout
)
response.raise_for_status()
result = response.json()
# In Cache speichern (nur GET)
if method.upper() == 'GET' and use_cache and self.cache:
self.cache.set(cache_key, result, expire=self.config.cache_ttl)
return result
except requests.exceptions.HTTPError as e:
error_msg = f"HTTP-Fehler: {e}"
try:
error_detail = e.response.json()
error_msg = f"{error_msg} - {error_detail}"
except (ValueError, AttributeError):
pass
raise PaperlessAPIError(
error_msg,
status_code=e.response.status_code if e.response else None
)
except requests.exceptions.ConnectionError as e:
raise PaperlessAPIError(f"Verbindungsfehler: Kann {self.base_url} nicht erreichen")
except requests.exceptions.Timeout as e:
raise PaperlessAPIError(f"Timeout nach {self.timeout}s")
except requests.exceptions.RequestException as e:
raise PaperlessAPIError(f"Request-Fehler: {e}")
def _get_paginated(
self,
endpoint: str,
params: Optional[dict] = None,
page_size: int = 100
) -> Generator[dict, None, None]:
"""
Holt alle Seiten eines paginierten Endpunkts.
Args:
endpoint: API-Endpunkt
params: Zusätzliche Query-Parameter
page_size: Anzahl Ergebnisse pro Seite
Yields:
Einzelne Ergebnis-Objekte
"""
params = params or {}
params['page_size'] = page_size
page = 1
while True:
params['page'] = page
logger.debug(f"Lade Seite {page} von {endpoint}")
response = self._request('GET', endpoint, params=params)
results = response.get('results', [])
for item in results:
yield item
# Prüfen ob weitere Seiten existieren
if not response.get('next'):
break
page += 1
def test_connection(self) -> bool:
"""
Testet die Verbindung zur Paperless-API.
Returns:
True wenn Verbindung erfolgreich
"""
try:
self._request('GET', self.ENDPOINTS['tags'], params={'page_size': 1})
return True
except PaperlessAPIError:
return False
# ==================== Custom Fields ====================
def get_custom_fields(self, refresh: bool = False) -> Dict[int, dict]:
"""
Holt alle Custom Field Definitionen.
Args:
refresh: Cache ignorieren und neu laden
Returns:
Dictionary mit Field-ID als Key und Definition als Value
"""
if self._custom_fields_cache is not None and not refresh:
return self._custom_fields_cache
fields = {}
for field in self._get_paginated(self.ENDPOINTS['custom_fields']):
fields[field['id']] = field
self._custom_fields_cache = fields
logger.info(f"Geladen: {len(fields)} Custom Fields")
return fields
def get_custom_field_by_name(self, name: str) -> Optional[dict]:
"""
Findet ein Custom Field anhand des Namens.
Args:
name: Name des Custom Fields
Returns:
Field-Definition oder None
"""
fields = self.get_custom_fields()
for field in fields.values():
if field['name'].lower() == name.lower():
return field
return None
# ==================== Tags ====================
def get_tags(self, refresh: bool = False) -> Dict[int, dict]:
"""
Holt alle Tags.
Returns:
Dictionary mit Tag-ID als Key
"""
if self._tags_cache is not None and not refresh:
return self._tags_cache
tags = {}
for tag in self._get_paginated(self.ENDPOINTS['tags']):
tags[tag['id']] = tag
self._tags_cache = tags
logger.info(f"Geladen: {len(tags)} Tags")
return tags
def get_tag_by_name(self, name: str) -> Optional[dict]:
"""Findet einen Tag anhand des Namens."""
tags = self.get_tags()
for tag in tags.values():
if tag['name'].lower() == name.lower():
return tag
return None
def get_tag_id(self, name: str) -> Optional[int]:
"""Holt die ID eines Tags anhand des Namens."""
tag = self.get_tag_by_name(name)
return tag['id'] if tag else None
# ==================== Correspondents ====================
def get_correspondents(self, refresh: bool = False) -> Dict[int, dict]:
"""
Holt alle Korrespondenten.
Returns:
Dictionary mit Correspondent-ID als Key
"""
if self._correspondents_cache is not None and not refresh:
return self._correspondents_cache
correspondents = {}
for corr in self._get_paginated(self.ENDPOINTS['correspondents']):
correspondents[corr['id']] = corr
self._correspondents_cache = correspondents
logger.info(f"Geladen: {len(correspondents)} Korrespondenten")
return correspondents
def get_correspondent_name(self, correspondent_id: int) -> str:
"""Holt den Namen eines Korrespondenten."""
correspondents = self.get_correspondents()
corr = correspondents.get(correspondent_id)
return corr['name'] if corr else f"Unbekannt ({correspondent_id})"
# ==================== Document Types ====================
def get_document_types(self, refresh: bool = False) -> Dict[int, dict]:
"""Holt alle Dokumenttypen."""
if self._document_types_cache is not None and not refresh:
return self._document_types_cache
doc_types = {}
for dt in self._get_paginated(self.ENDPOINTS['document_types']):
doc_types[dt['id']] = dt
self._document_types_cache = doc_types
return doc_types
# ==================== Documents ====================
def get_documents(
self,
tags: Optional[List[Union[int, str]]] = None,
correspondent: Optional[Union[int, str]] = None,
document_type: Optional[Union[int, str]] = None,
year: Optional[int] = None,
month: Optional[int] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
query: Optional[str] = None,
ordering: str = '-archive_date',
**extra_filters
) -> List[dict]:
"""
Holt Dokumente mit optionalen Filtern.
Args:
tags: Liste von Tag-IDs oder Namen
correspondent: Korrespondent-ID oder Name
document_type: Dokumenttyp-ID oder Name
year: Jahr (für archive_date)
month: Monat (1-12, nur zusammen mit year)
date_from: Startdatum
date_to: Enddatum
query: Volltextsuche
ordering: Sortierung
**extra_filters: Zusätzliche Filter für die API
Returns:
Liste von Dokumenten
"""
params = {'ordering': ordering}
# Tags verarbeiten
if tags:
tag_ids = []
for tag in tags:
if isinstance(tag, int):
tag_ids.append(tag)
else:
tag_id = self.get_tag_id(tag)
if tag_id:
tag_ids.append(tag_id)
else:
logger.warning(f"Tag nicht gefunden: {tag}")
if tag_ids:
params['tags__id__in'] = ','.join(str(t) for t in tag_ids)
# Korrespondent
if correspondent:
if isinstance(correspondent, str):
correspondents = self.get_correspondents()
for c in correspondents.values():
if c['name'].lower() == correspondent.lower():
params['correspondent__id'] = c['id']
break
else:
params['correspondent__id'] = correspondent
# Dokumenttyp
if document_type:
if isinstance(document_type, str):
doc_types = self.get_document_types()
for dt in doc_types.values():
if dt['name'].lower() == document_type.lower():
params['document_type__id'] = dt['id']
break
else:
params['document_type__id'] = document_type
# Datumsfilter
date_field = self.config.date_field
if year:
if month:
# Spezifischer Monat
if month == 12:
next_year = year + 1
next_month = 1
else:
next_year = year
next_month = month + 1
params[f'{date_field}__gte'] = f'{year}-{month:02d}-01'
params[f'{date_field}__lt'] = f'{next_year}-{next_month:02d}-01'
else:
# Ganzes Jahr
params[f'{date_field}__year'] = year
if date_from:
params[f'{date_field}__gte'] = date_from.strftime('%Y-%m-%d')
if date_to:
params[f'{date_field}__lte'] = date_to.strftime('%Y-%m-%d')
# Volltextsuche
if query:
params['query'] = query
# Extra-Filter
params.update(extra_filters)
# Alle Dokumente abrufen
documents = list(self._get_paginated(self.ENDPOINTS['documents'], params))
logger.info(f"Geladen: {len(documents)} Dokumente")
return documents
def get_document(self, document_id: int) -> dict:
"""
Holt ein einzelnes Dokument.
Args:
document_id: ID des Dokuments
Returns:
Dokument-Dictionary
"""
endpoint = f"{self.ENDPOINTS['documents']}{document_id}/"
return self._request('GET', endpoint)
def get_document_url(self, document_id: int) -> str:
"""Generiert die Web-URL für ein Dokument."""
return f"{self.base_url}/documents/{document_id}/details"
def get_document_download_url(self, document_id: int) -> str:
"""Generiert die Download-URL für ein Dokument."""
return f"{self.base_url}/api/documents/{document_id}/download/"
# ==================== Hilfsmethoden ====================
def resolve_all_metadata(self, documents: List[dict]) -> List[dict]:
"""
Erweitert Dokumente um aufgelöste Metadaten (Tag-Namen, Korrespondent-Namen, etc.).
Args:
documents: Liste von Dokumenten
Returns:
Erweiterte Dokumente
"""
tags = self.get_tags()
correspondents = self.get_correspondents()
doc_types = self.get_document_types()
custom_fields = self.get_custom_fields()
for doc in documents:
# Tag-Namen
doc['tag_names'] = [
tags.get(tid, {}).get('name', f'Unknown-{tid}')
for tid in doc.get('tags', [])
]
# Korrespondent-Name
corr_id = doc.get('correspondent')
doc['correspondent_name'] = (
correspondents.get(corr_id, {}).get('name', '')
if corr_id else ''
)
# Dokumenttyp-Name
dt_id = doc.get('document_type')
doc['document_type_name'] = (
doc_types.get(dt_id, {}).get('name', '')
if dt_id else ''
)
# Custom Fields aufbereiten
doc['custom_fields_resolved'] = {}
for cf in doc.get('custom_fields', []):
field_id = cf.get('field')
field_def = custom_fields.get(field_id, {})
field_name = field_def.get('name', f'field_{field_id}')
doc['custom_fields_resolved'][field_name] = {
'value': cf.get('value'),
'type': field_def.get('data_type', 'string'),
'field_id': field_id
}
# URL hinzufügen
doc['web_url'] = self.get_document_url(doc['id'])
return documents
def get_statistics(self) -> dict:
"""
Holt allgemeine Statistiken.
Returns:
Dictionary mit Statistiken
"""
return {
'total_documents': len(list(self._get_paginated(
self.ENDPOINTS['documents'],
params={'page_size': 1}
))),
'total_tags': len(self.get_tags()),
'total_correspondents': len(self.get_correspondents()),
'total_custom_fields': len(self.get_custom_fields()),
}
+628
View File
@@ -0,0 +1,628 @@
"""
Report Generator für das Paperless Finance Report Tool.
Generiert Berichte in verschiedenen Formaten: CLI, HTML, PDF, JSON.
"""
import json
import logging
import os
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from jinja2 import Environment, FileSystemLoader, select_autoescape
from config import Config, get_config
from extractor import AggregationResult, FinanceDocument, GroupStats
logger = logging.getLogger(__name__)
class DecimalEncoder(json.JSONEncoder):
"""JSON Encoder für Decimal-Werte."""
def default(self, obj):
if isinstance(obj, Decimal):
return float(obj)
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, FinanceDocument):
return {
'id': obj.id,
'title': obj.title,
'betrag': float(obj.betrag) if obj.betrag else None,
'effective_date': obj.effective_date.isoformat() if obj.effective_date else None,
'correspondent': obj.correspondent,
'kategorie': obj.kategorie,
'tags': obj.tags,
'web_url': obj.web_url,
}
if isinstance(obj, GroupStats):
return {
'name': obj.name,
'amount': float(obj.amount),
'count': obj.count,
'percentage': obj.percentage,
}
return super().default(obj)
class ReportGenerator:
"""Generiert Finanzberichte in verschiedenen Formaten."""
def __init__(self, config: Optional[Config] = None):
"""
Initialisiert den Report Generator.
Args:
config: Konfiguration
"""
self.config = config or get_config()
self.currency = self.config.currency
# Jinja2 Template-Umgebung
template_dir = Path(__file__).parent / 'templates'
self.jinja_env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=select_autoescape(['html', 'xml']),
)
# Custom Filter registrieren
self.jinja_env.filters['format_amount'] = self._format_amount
self.jinja_env.filters['format_percent'] = self._format_percent
self.jinja_env.filters['format_date'] = self._format_date
def _format_amount(self, value: Optional[Decimal], with_currency: bool = True) -> str:
"""Formatiert einen Betrag."""
if value is None:
return '-'
formatted = f"{value:,.2f}".replace(',', "'")
if with_currency:
return f"{self.currency} {formatted}"
return formatted
def _format_percent(self, value: float) -> str:
"""Formatiert einen Prozentwert."""
return f"{value:.1f}%"
def _format_date(self, value: Optional[datetime], fmt: str = '%d.%m.%Y') -> str:
"""Formatiert ein Datum."""
if value is None:
return '-'
return value.strftime(fmt)
def _ensure_output_dir(self) -> Path:
"""Stellt sicher, dass das Ausgabeverzeichnis existiert."""
output_dir = self.config.output_path
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
def _get_output_filename(
self,
year: Optional[int] = None,
month: Optional[int] = None,
extension: str = 'html'
) -> str:
"""Generiert den Ausgabe-Dateinamen."""
pattern = self.config.get('output.filename_pattern', 'finanzbericht_{year}')
now = datetime.now()
filename = pattern.format(
year=year or now.year,
month=month or now.month,
date=now.strftime('%Y-%m-%d'),
timestamp=now.strftime('%Y%m%d_%H%M%S'),
)
return f"{filename}.{extension}"
# ==================== CLI Output ====================
def generate_cli(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
detail: bool = False
) -> str:
"""
Generiert CLI-Ausgabe.
Args:
result: Aggregationsergebnis
title: Berichtstitel
detail: Detailansicht aktivieren
Returns:
Formatierter String für CLI-Ausgabe
"""
lines = []
sep = "=" * 60
# Header
lines.append(sep)
lines.append(title.center(60))
lines.append(sep)
lines.append("")
# Übersicht
lines.append(f"Dokumente gesamt: {result.document_count}")
lines.append(f" - mit Betrag: {result.documents_with_amount}")
lines.append(f" - ohne Betrag: {result.documents_without_amount}")
lines.append("")
lines.append(f"Gesamtsumme: {self._format_amount(result.total_amount)}")
lines.append(f"Durchschnitt: {self._format_amount(result.average_amount)}")
lines.append(f"Median: {self._format_amount(result.median_amount)}")
lines.append(f"Minimum: {self._format_amount(result.min_amount)}")
lines.append(f"Maximum: {self._format_amount(result.max_amount)}")
lines.append("")
# Nach Tag
if result.by_tag:
lines.append("-" * 60)
lines.append("Nach Tag:")
lines.append("-" * 60)
for name, stats in result.by_tag.items():
amount_str = self._format_amount(stats.amount).rjust(18)
pct_str = f"({stats.percentage:5.1f}%)"
lines.append(f" {name:<25} {amount_str} {pct_str}")
lines.append("")
# Nach Korrespondent
if result.by_correspondent and detail:
lines.append("-" * 60)
lines.append("Nach Korrespondent:")
lines.append("-" * 60)
for name, stats in list(result.by_correspondent.items())[:15]:
amount_str = self._format_amount(stats.amount).rjust(18)
pct_str = f"({stats.percentage:5.1f}%)"
lines.append(f" {name[:25]:<25} {amount_str} {pct_str}")
if len(result.by_correspondent) > 15:
lines.append(f" ... und {len(result.by_correspondent) - 15} weitere")
lines.append("")
# Nach Kategorie
if result.by_category:
lines.append("-" * 60)
lines.append("Nach Kategorie:")
lines.append("-" * 60)
for name, stats in result.by_category.items():
amount_str = self._format_amount(stats.amount).rjust(18)
pct_str = f"({stats.percentage:5.1f}%)"
lines.append(f" {name[:25]:<25} {amount_str} {pct_str}")
lines.append("")
# Nach Monat
if result.by_month:
lines.append("-" * 60)
lines.append("Nach Monat:")
lines.append("-" * 60)
for month, stats in result.by_month.items():
amount_str = self._format_amount(stats.amount).rjust(18)
lines.append(f" {month:<10} {amount_str} ({stats.count} Dok.)")
lines.append("")
# Nach Zahlungsart
if result.by_payment_type and detail:
lines.append("-" * 60)
lines.append("Nach Zahlungsart:")
lines.append("-" * 60)
for name, stats in result.by_payment_type.items():
amount_str = self._format_amount(stats.amount).rjust(18)
pct_str = f"({stats.percentage:5.1f}%)"
lines.append(f" {name:<25} {amount_str} {pct_str}")
lines.append("")
# Top-Posten
if result.top_items and detail:
lines.append("-" * 60)
lines.append("Top 10 Einzelposten:")
lines.append("-" * 60)
for i, doc in enumerate(result.top_items[:10], 1):
amount_str = self._format_amount(doc.betrag).rjust(18)
title = doc.title[:35]
lines.append(f" {i:2}. {title:<35} {amount_str}")
lines.append("")
lines.append(sep)
lines.append(f"Generiert: {datetime.now().strftime('%d.%m.%Y %H:%M')}")
lines.append(sep)
return "\n".join(lines)
# ==================== HTML Output ====================
def generate_html(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
year: Optional[int] = None,
month: Optional[int] = None,
comparison: Optional[Dict] = None
) -> str:
"""
Generiert HTML-Bericht.
Args:
result: Aggregationsergebnis
title: Berichtstitel
year: Jahr für den Bericht
month: Monat für den Bericht (optional)
comparison: Vergleichsdaten (optional)
Returns:
HTML-String
"""
template = self.jinja_env.get_template('report.html')
# Chart-Daten vorbereiten
tag_chart_data = self._prepare_chart_data(result.by_tag)
category_chart_data = self._prepare_chart_data(result.by_category)
month_chart_data = self._prepare_line_chart_data(result.by_month)
correspondent_chart_data = self._prepare_chart_data(
dict(list(result.by_correspondent.items())[:10])
)
context = {
'title': title,
'year': year,
'month': month,
'currency': self.currency,
'generated_at': datetime.now(),
'result': result,
'comparison': comparison,
# Chart-Daten als JSON
'tag_chart_data': json.dumps(tag_chart_data),
'category_chart_data': json.dumps(category_chart_data),
'month_chart_data': json.dumps(month_chart_data),
'correspondent_chart_data': json.dumps(correspondent_chart_data),
}
return template.render(**context)
def _prepare_chart_data(self, groups: Dict[str, GroupStats]) -> Dict[str, Any]:
"""Bereitet Daten für ein Balken-/Kreisdiagramm vor."""
labels = []
values = []
colors = self._generate_colors(len(groups))
for name, stats in groups.items():
labels.append(name)
values.append(float(stats.amount))
return {
'labels': labels,
'values': values,
'colors': colors,
}
def _prepare_line_chart_data(self, groups: Dict[str, GroupStats]) -> Dict[str, Any]:
"""Bereitet Daten für ein Liniendiagramm vor."""
# Nach Datum sortieren
sorted_items = sorted(groups.items())
labels = [item[0] for item in sorted_items]
values = [float(item[1].amount) for item in sorted_items]
return {
'labels': labels,
'values': values,
}
def _generate_colors(self, count: int) -> List[str]:
"""Generiert eine Farbpalette."""
# Vordefinierte Farben
colors = [
'#2E86AB', # Blau
'#A23B72', # Magenta
'#F18F01', # Orange
'#C73E1D', # Rot
'#3B1F2B', # Dunkelrot
'#95C623', # Grün
'#5C5D67', # Grau
'#E8D21D', # Gelb
'#1B998B', # Türkis
'#7768AE', # Lila
]
# Farben wiederholen falls nötig
while len(colors) < count:
colors.extend(colors)
return colors[:count]
def save_html(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
year: Optional[int] = None,
month: Optional[int] = None,
comparison: Optional[Dict] = None,
filename: Optional[str] = None
) -> Path:
"""
Speichert HTML-Bericht als Datei.
Returns:
Pfad zur erstellten Datei
"""
html = self.generate_html(result, title, year, month, comparison)
output_dir = self._ensure_output_dir()
if filename is None:
filename = self._get_output_filename(year, month, 'html')
output_path = output_dir / filename
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html)
logger.info(f"HTML-Bericht gespeichert: {output_path}")
return output_path
# ==================== PDF Output ====================
def generate_pdf(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
year: Optional[int] = None,
month: Optional[int] = None,
comparison: Optional[Dict] = None
) -> bytes:
"""
Generiert PDF-Bericht.
Returns:
PDF als Bytes
"""
try:
from weasyprint import HTML, CSS
except ImportError:
raise ImportError(
"WeasyPrint ist nicht installiert. "
"Installiere mit: pip install weasyprint"
)
# HTML generieren
html_content = self.generate_html(result, title, year, month, comparison)
# PDF generieren
html = HTML(string=html_content)
# Zusätzliches CSS für PDF
pdf_css = CSS(string='''
@page {
size: A4;
margin: 2cm;
}
body {
font-size: 10pt;
}
.chart-container {
page-break-inside: avoid;
}
table {
page-break-inside: avoid;
}
''')
return html.write_pdf(stylesheets=[pdf_css])
def save_pdf(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
year: Optional[int] = None,
month: Optional[int] = None,
comparison: Optional[Dict] = None,
filename: Optional[str] = None
) -> Path:
"""
Speichert PDF-Bericht als Datei.
Returns:
Pfad zur erstellten Datei
"""
pdf_bytes = self.generate_pdf(result, title, year, month, comparison)
output_dir = self._ensure_output_dir()
if filename is None:
filename = self._get_output_filename(year, month, 'pdf')
output_path = output_dir / filename
with open(output_path, 'wb') as f:
f.write(pdf_bytes)
logger.info(f"PDF-Bericht gespeichert: {output_path}")
return output_path
# ==================== JSON Output ====================
def generate_json(
self,
result: AggregationResult,
indent: int = 2
) -> str:
"""
Generiert JSON-Ausgabe.
Returns:
JSON-String
"""
data = {
'generated_at': datetime.now().isoformat(),
'currency': self.currency,
'summary': {
'total_amount': result.total_amount,
'document_count': result.document_count,
'documents_with_amount': result.documents_with_amount,
'documents_without_amount': result.documents_without_amount,
'average_amount': result.average_amount,
'median_amount': result.median_amount,
'min_amount': result.min_amount,
'max_amount': result.max_amount,
},
'by_tag': result.by_tag,
'by_correspondent': result.by_correspondent,
'by_category': result.by_category,
'by_payment_type': result.by_payment_type,
'by_month': result.by_month,
'top_items': result.top_items[:20],
'documents': result.documents,
}
return json.dumps(data, indent=indent, cls=DecimalEncoder, ensure_ascii=False)
def save_json(
self,
result: AggregationResult,
year: Optional[int] = None,
month: Optional[int] = None,
filename: Optional[str] = None
) -> Path:
"""
Speichert JSON-Bericht als Datei.
Returns:
Pfad zur erstellten Datei
"""
json_str = self.generate_json(result)
output_dir = self._ensure_output_dir()
if filename is None:
filename = self._get_output_filename(year, month, 'json')
output_path = output_dir / filename
with open(output_path, 'w', encoding='utf-8') as f:
f.write(json_str)
logger.info(f"JSON-Bericht gespeichert: {output_path}")
return output_path
# ==================== CSV Output ====================
def generate_csv(
self,
documents: List[FinanceDocument],
delimiter: str = ';'
) -> str:
"""
Generiert CSV-Export der Dokumente.
Returns:
CSV-String
"""
lines = []
# Header
headers = [
'ID', 'Titel', 'Datum', 'Betrag', 'Korrespondent',
'Kategorie', 'Zahlungsart', 'Tags', 'URL'
]
lines.append(delimiter.join(headers))
# Daten
for doc in documents:
row = [
str(doc.id),
f'"{doc.title}"' if delimiter in doc.title else doc.title,
self._format_date(doc.effective_date),
self._format_amount(doc.betrag, with_currency=False) if doc.betrag else '',
doc.correspondent or '',
doc.kategorie or '',
doc.zahlungsart or '',
', '.join(doc.tags),
doc.web_url or '',
]
lines.append(delimiter.join(row))
return '\n'.join(lines)
def save_csv(
self,
documents: List[FinanceDocument],
year: Optional[int] = None,
month: Optional[int] = None,
filename: Optional[str] = None
) -> Path:
"""Speichert CSV-Export als Datei."""
csv_str = self.generate_csv(documents)
output_dir = self._ensure_output_dir()
if filename is None:
filename = self._get_output_filename(year, month, 'csv')
output_path = output_dir / filename
with open(output_path, 'w', encoding='utf-8-sig') as f: # BOM für Excel
f.write(csv_str)
logger.info(f"CSV-Export gespeichert: {output_path}")
return output_path
# ==================== Vergleichsbericht ====================
def generate_comparison_cli(self, comparison: Dict) -> str:
"""Generiert CLI-Ausgabe für Periodenvergleich."""
lines = []
sep = "=" * 70
p1 = comparison['period1']
p2 = comparison['period2']
lines.append(sep)
lines.append(f"Vergleich: {p1['name']} vs {p2['name']}".center(70))
lines.append(sep)
lines.append("")
# Übersicht
lines.append(f"{'Kennzahl':<30} {p1['name']:>15} {p2['name']:>15} {'Diff':>10}")
lines.append("-" * 70)
lines.append(
f"{'Gesamtsumme':<30} "
f"{self._format_amount(p1['total'], False):>15} "
f"{self._format_amount(p2['total'], False):>15} "
f"{comparison['diff_percent']:>+9.1f}%"
)
lines.append(
f"{'Anzahl Dokumente':<30} "
f"{p1['count']:>15} "
f"{p2['count']:>15} "
f"{p2['count'] - p1['count']:>+10}"
)
lines.append("")
lines.append("-" * 70)
lines.append("Nach Kategorie:")
lines.append("-" * 70)
for cat, data in sorted(
comparison['category_comparison'].items(),
key=lambda x: abs(x[1]['diff_absolute']),
reverse=True
):
status = ""
if data['status'] == 'new':
status = "[NEU]"
elif data['status'] == 'removed':
status = "[ENTF]"
lines.append(
f" {cat[:25]:<25} "
f"{self._format_amount(data['period1'], False):>12} "
f"{self._format_amount(data['period2'], False):>12} "
f"{data['diff_percent']:>+8.1f}% "
f"{status}"
)
lines.append("")
lines.append(sep)
return "\n".join(lines)
+31
View File
@@ -0,0 +1,31 @@
# Paperless Finance Report Tool - Dependencies
# HTTP Client
requests>=2.31.0
# CLI Framework
click>=8.1.7
# Configuration
pyyaml>=6.0.1
# HTML Templating
jinja2>=3.1.2
# PDF Generation
weasyprint>=60.1
# Data Processing
python-dateutil>=2.8.2
# Caching
diskcache>=5.6.3
# Logging (colorized output)
colorlog>=6.8.0
# Progress bars
tqdm>=4.66.1
# Table formatting for CLI
tabulate>=0.9.0
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Setup-Skript für das Paperless Finance Report Tool.
"""
from setuptools import setup, find_packages
from pathlib import Path
# README einlesen
readme_path = Path(__file__).parent / 'README.md'
long_description = ''
if readme_path.exists():
long_description = readme_path.read_text(encoding='utf-8')
setup(
name='paperless-report',
version='1.0.0',
description='Finanz-Auswertungstool für Paperless-ngx',
long_description=long_description,
long_description_content_type='text/markdown',
author='Your Name',
author_email='your.email@example.com',
url='https://github.com/yourusername/paperless-report',
license='MIT',
py_modules=[
'main',
'config',
'paperless_client',
'extractor',
'report_generator',
],
include_package_data=True,
package_data={
'': ['templates/*.html', 'config.yaml.example'],
},
install_requires=[
'requests>=2.31.0',
'click>=8.1.7',
'pyyaml>=6.0.1',
'jinja2>=3.1.2',
'python-dateutil>=2.8.2',
'tabulate>=0.9.0',
'tqdm>=4.66.1',
],
extras_require={
'full': [
'weasyprint>=60.1',
'diskcache>=5.6.3',
'colorlog>=6.8.0',
],
'dev': [
'pytest>=7.4.0',
'pytest-cov>=4.1.0',
'black>=23.7.0',
'flake8>=6.1.0',
'mypy>=1.5.0',
],
},
entry_points={
'console_scripts': [
'paperless-report=main:main',
],
},
python_requires='>=3.8',
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Console',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Topic :: Office/Business :: Financial :: Accounting',
],
keywords='paperless paperless-ngx finance report accounting',
)
+848
View File
@@ -0,0 +1,848 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root {
--primary-color: #2E86AB;
--secondary-color: #A23B72;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--light-bg: #f8f9fa;
--border-color: #dee2e6;
--text-color: #212529;
--text-muted: #6c757d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: #fff;
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
h1, h2, h3 {
margin-bottom: 1rem;
color: var(--primary-color);
}
h1 {
font-size: 2rem;
border-bottom: 3px solid var(--primary-color);
padding-bottom: 0.5rem;
margin-bottom: 1.5rem;
}
h2 {
font-size: 1.5rem;
margin-top: 2rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header-info {
text-align: right;
color: var(--text-muted);
font-size: 0.9rem;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.card {
background: var(--light-bg);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card-title {
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.card-value {
font-size: 1.75rem;
font-weight: bold;
color: var(--primary-color);
}
.card-value.highlight {
color: var(--secondary-color);
}
.card-subtitle {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2rem;
margin: 2rem 0;
}
.chart-wrapper {
background: var(--light-bg);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-wrapper h3 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.chart-container {
position: relative;
height: 300px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.9rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--light-bg);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
}
tr:hover {
background-color: var(--light-bg);
}
.text-right {
text-align: right;
}
.amount {
font-family: 'SF Mono', Consolas, monospace;
white-space: nowrap;
}
.amount-positive {
color: var(--danger-color);
}
.percentage {
color: var(--text-muted);
font-size: 0.85rem;
}
.progress-bar {
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin-top: 0.25rem;
}
.progress-bar-fill {
height: 100%;
background-color: var(--primary-color);
border-radius: 4px;
}
.tag {
display: inline-block;
padding: 0.2rem 0.6rem;
background-color: var(--primary-color);
color: white;
border-radius: 12px;
font-size: 0.75rem;
margin: 0.1rem;
}
.document-link {
color: var(--primary-color);
text-decoration: none;
}
.document-link:hover {
text-decoration: underline;
}
.section {
margin: 2rem 0;
}
.comparison-table {
margin-top: 1rem;
}
.comparison-table .change-positive {
color: var(--danger-color);
}
.comparison-table .change-negative {
color: var(--success-color);
}
.comparison-table .new-item {
background-color: #fff3cd;
}
.comparison-table .removed-item {
background-color: #f8d7da;
}
.export-buttons {
margin: 2rem 0;
display: flex;
gap: 1rem;
}
.btn {
display: inline-block;
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: white;
text-decoration: none;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover {
opacity: 0.9;
}
.btn-secondary {
background-color: var(--text-muted);
}
.footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
@media print {
body {
padding: 0;
}
.export-buttons {
display: none;
}
.chart-wrapper {
page-break-inside: avoid;
}
table {
page-break-inside: avoid;
}
}
@media (max-width: 768px) {
.charts-container {
grid-template-columns: 1fr;
}
.summary-cards {
grid-template-columns: 1fr 1fr;
}
}
</style>
</head>
<body>
<div class="header">
<h1>{{ title }}</h1>
<div class="header-info">
{% if year %}
<div><strong>Zeitraum:</strong>
{% if month %}
{{ '%02d'|format(month) }}/{{ year }}
{% else %}
{{ year }}
{% endif %}
</div>
{% endif %}
<div><strong>Generiert:</strong> {{ generated_at|format_date('%d.%m.%Y %H:%M') }}</div>
</div>
</div>
<!-- Zusammenfassung -->
<div class="summary-cards">
<div class="card">
<div class="card-title">Gesamtsumme</div>
<div class="card-value highlight">{{ result.total_amount|format_amount }}</div>
<div class="card-subtitle">{{ result.documents_with_amount }} Dokumente mit Betrag</div>
</div>
<div class="card">
<div class="card-title">Dokumente</div>
<div class="card-value">{{ result.document_count }}</div>
<div class="card-subtitle">{{ result.documents_without_amount }} ohne Betrag</div>
</div>
<div class="card">
<div class="card-title">Durchschnitt</div>
<div class="card-value">{{ result.average_amount|format_amount }}</div>
<div class="card-subtitle">pro Dokument</div>
</div>
<div class="card">
<div class="card-title">Median</div>
<div class="card-value">{{ result.median_amount|format_amount }}</div>
<div class="card-subtitle">Min: {{ result.min_amount|format_amount }}</div>
</div>
</div>
<!-- Diagramme -->
<div class="charts-container">
{% if result.by_tag %}
<div class="chart-wrapper">
<h3>Verteilung nach Tag</h3>
<div class="chart-container">
<canvas id="tagChart"></canvas>
</div>
</div>
{% endif %}
{% if result.by_category %}
<div class="chart-wrapper">
<h3>Verteilung nach Kategorie</h3>
<div class="chart-container">
<canvas id="categoryChart"></canvas>
</div>
</div>
{% endif %}
{% if result.by_month %}
<div class="chart-wrapper">
<h3>Monatsverlauf</h3>
<div class="chart-container">
<canvas id="monthChart"></canvas>
</div>
</div>
{% endif %}
{% if result.by_correspondent %}
<div class="chart-wrapper">
<h3>Top 10 Korrespondenten</h3>
<div class="chart-container">
<canvas id="correspondentChart"></canvas>
</div>
</div>
{% endif %}
</div>
<!-- Nach Tag -->
{% if result.by_tag %}
<div class="section">
<h2>Nach Tag</h2>
<table>
<thead>
<tr>
<th>Tag</th>
<th class="text-right">Betrag</th>
<th class="text-right">Anteil</th>
<th class="text-right">Anzahl</th>
<th style="width: 200px;">Verteilung</th>
</tr>
</thead>
<tbody>
{% for name, stats in result.by_tag.items() %}
<tr>
<td><span class="tag">{{ name }}</span></td>
<td class="text-right amount">{{ stats.amount|format_amount }}</td>
<td class="text-right percentage">{{ stats.percentage|format_percent }}</td>
<td class="text-right">{{ stats.count }}</td>
<td>
<div class="progress-bar">
<div class="progress-bar-fill" style="width: {{ stats.percentage }}%"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Nach Kategorie -->
{% if result.by_category %}
<div class="section">
<h2>Nach Kategorie</h2>
<table>
<thead>
<tr>
<th>Kategorie</th>
<th class="text-right">Betrag</th>
<th class="text-right">Anteil</th>
<th class="text-right">Anzahl</th>
<th style="width: 200px;">Verteilung</th>
</tr>
</thead>
<tbody>
{% for name, stats in result.by_category.items() %}
<tr>
<td>{{ name }}</td>
<td class="text-right amount">{{ stats.amount|format_amount }}</td>
<td class="text-right percentage">{{ stats.percentage|format_percent }}</td>
<td class="text-right">{{ stats.count }}</td>
<td>
<div class="progress-bar">
<div class="progress-bar-fill" style="width: {{ stats.percentage }}%"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Nach Monat -->
{% if result.by_month %}
<div class="section">
<h2>Nach Monat</h2>
<table>
<thead>
<tr>
<th>Monat</th>
<th class="text-right">Betrag</th>
<th class="text-right">Anzahl</th>
</tr>
</thead>
<tbody>
{% for month, stats in result.by_month.items() %}
<tr>
<td>{{ month }}</td>
<td class="text-right amount">{{ stats.amount|format_amount }}</td>
<td class="text-right">{{ stats.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Nach Korrespondent -->
{% if result.by_correspondent %}
<div class="section">
<h2>Nach Korrespondent</h2>
<table>
<thead>
<tr>
<th>Korrespondent</th>
<th class="text-right">Betrag</th>
<th class="text-right">Anteil</th>
<th class="text-right">Anzahl</th>
</tr>
</thead>
<tbody>
{% for name, stats in result.by_correspondent.items() %}
<tr>
<td>{{ name }}</td>
<td class="text-right amount">{{ stats.amount|format_amount }}</td>
<td class="text-right percentage">{{ stats.percentage|format_percent }}</td>
<td class="text-right">{{ stats.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Top Einzelposten -->
{% if result.top_items %}
<div class="section">
<h2>Top 10 Einzelposten</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Titel</th>
<th>Datum</th>
<th>Korrespondent</th>
<th class="text-right">Betrag</th>
</tr>
</thead>
<tbody>
{% for doc in result.top_items[:10] %}
<tr>
<td>{{ loop.index }}</td>
<td>
{% if doc.web_url %}
<a href="{{ doc.web_url }}" class="document-link" target="_blank">{{ doc.title }}</a>
{% else %}
{{ doc.title }}
{% endif %}
</td>
<td>{{ doc.effective_date|format_date }}</td>
<td>{{ doc.correspondent or '-' }}</td>
<td class="text-right amount amount-positive">{{ doc.betrag|format_amount }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Vergleich -->
{% if comparison %}
<div class="section">
<h2>Periodenvergleich: {{ comparison.period1.name }} vs {{ comparison.period2.name }}</h2>
<div class="summary-cards">
<div class="card">
<div class="card-title">{{ comparison.period1.name }}</div>
<div class="card-value">{{ comparison.period1.total|format_amount }}</div>
<div class="card-subtitle">{{ comparison.period1.count }} Dokumente</div>
</div>
<div class="card">
<div class="card-title">{{ comparison.period2.name }}</div>
<div class="card-value">{{ comparison.period2.total|format_amount }}</div>
<div class="card-subtitle">{{ comparison.period2.count }} Dokumente</div>
</div>
<div class="card">
<div class="card-title">Veränderung</div>
<div class="card-value {% if comparison.diff_percent > 0 %}change-positive{% else %}change-negative{% endif %}">
{{ '%+.1f'|format(comparison.diff_percent) }}%
</div>
<div class="card-subtitle">{{ comparison.diff_absolute|format_amount }}</div>
</div>
</div>
<table class="comparison-table">
<thead>
<tr>
<th>Kategorie</th>
<th class="text-right">{{ comparison.period1.name }}</th>
<th class="text-right">{{ comparison.period2.name }}</th>
<th class="text-right">Differenz</th>
<th class="text-right">Veränderung</th>
</tr>
</thead>
<tbody>
{% for cat, data in comparison.category_comparison.items() %}
<tr class="{% if data.status == 'new' %}new-item{% elif data.status == 'removed' %}removed-item{% endif %}">
<td>{{ cat }}</td>
<td class="text-right amount">{{ data.period1|format_amount }}</td>
<td class="text-right amount">{{ data.period2|format_amount }}</td>
<td class="text-right amount">{{ data.diff_absolute|format_amount }}</td>
<td class="text-right {% if data.diff_percent > 0 %}change-positive{% else %}change-negative{% endif %}">
{{ '%+.1f'|format(data.diff_percent) }}%
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Alle Dokumente -->
<div class="section">
<h2>Alle Dokumente ({{ result.document_count }})</h2>
<table id="documentsTable">
<thead>
<tr>
<th>Datum</th>
<th>Titel</th>
<th>Korrespondent</th>
<th>Tags</th>
<th class="text-right">Betrag</th>
</tr>
</thead>
<tbody>
{% for doc in result.documents %}
<tr>
<td>{{ doc.effective_date|format_date }}</td>
<td>
{% if doc.web_url %}
<a href="{{ doc.web_url }}" class="document-link" target="_blank">{{ doc.title }}</a>
{% else %}
{{ doc.title }}
{% endif %}
</td>
<td>{{ doc.correspondent or '-' }}</td>
<td>
{% for tag in doc.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</td>
<td class="text-right amount">{{ doc.betrag|format_amount if doc.betrag else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Export-Buttons -->
<div class="export-buttons">
<button class="btn" onclick="window.print()">Drucken / PDF</button>
<button class="btn btn-secondary" onclick="exportTableToCSV()">CSV exportieren</button>
</div>
<div class="footer">
<p>Paperless Finance Report &bull; Generiert am {{ generated_at|format_date('%d.%m.%Y um %H:%M') }}</p>
</div>
<script>
// Chart-Daten von Jinja
const tagChartData = {{ tag_chart_data|safe }};
const categoryChartData = {{ category_chart_data|safe }};
const monthChartData = {{ month_chart_data|safe }};
const correspondentChartData = {{ correspondent_chart_data|safe }};
const currency = "{{ currency }}";
// Formatierung für Tooltips
function formatAmount(value) {
return currency + " " + value.toLocaleString('de-CH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
// Tag-Chart (Doughnut)
if (tagChartData.labels && tagChartData.labels.length > 0) {
const tagCtx = document.getElementById('tagChart');
if (tagCtx) {
new Chart(tagCtx, {
type: 'doughnut',
data: {
labels: tagChartData.labels,
datasets: [{
data: tagChartData.values,
backgroundColor: tagChartData.colors,
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
padding: 10
}
},
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + formatAmount(context.raw);
}
}
}
}
}
});
}
}
// Kategorie-Chart (Bar)
if (categoryChartData.labels && categoryChartData.labels.length > 0) {
const catCtx = document.getElementById('categoryChart');
if (catCtx) {
new Chart(catCtx, {
type: 'bar',
data: {
labels: categoryChartData.labels,
datasets: [{
data: categoryChartData.values,
backgroundColor: categoryChartData.colors,
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
return formatAmount(context.raw);
}
}
}
},
scales: {
x: {
ticks: {
callback: function(value) {
return formatAmount(value);
}
}
}
}
}
});
}
}
// Monats-Chart (Line)
if (monthChartData.labels && monthChartData.labels.length > 0) {
const monthCtx = document.getElementById('monthChart');
if (monthCtx) {
new Chart(monthCtx, {
type: 'line',
data: {
labels: monthChartData.labels,
datasets: [{
data: monthChartData.values,
borderColor: '#2E86AB',
backgroundColor: 'rgba(46, 134, 171, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
return formatAmount(context.raw);
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return formatAmount(value);
}
}
}
}
}
});
}
}
// Korrespondent-Chart (Bar horizontal)
if (correspondentChartData.labels && correspondentChartData.labels.length > 0) {
const corrCtx = document.getElementById('correspondentChart');
if (corrCtx) {
new Chart(corrCtx, {
type: 'bar',
data: {
labels: correspondentChartData.labels,
datasets: [{
data: correspondentChartData.values,
backgroundColor: '#A23B72',
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
return formatAmount(context.raw);
}
}
}
},
scales: {
x: {
ticks: {
callback: function(value) {
return formatAmount(value);
}
}
}
}
}
});
}
}
// CSV Export
function exportTableToCSV() {
const table = document.getElementById('documentsTable');
const rows = table.querySelectorAll('tr');
let csv = [];
rows.forEach(row => {
const cells = row.querySelectorAll('th, td');
const rowData = [];
cells.forEach(cell => {
let text = cell.innerText.replace(/"/g, '""');
if (text.includes(';') || text.includes('"') || text.includes('\n')) {
text = '"' + text + '"';
}
rowData.push(text);
});
csv.push(rowData.join(';'));
});
const csvContent = '\ufeff' + csv.join('\n'); // BOM for Excel
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'finanzbericht_export.csv';
link.click();
}
</script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
# Zwölfton-Synthesizer (Dodekaphonie)
Ein interaktiver Web-Synthesizer basierend auf Arnold Schönbergs Zwölftontechnik.
## Features
- **Automatische Zwölfton-Komposition**: Generiert fortlaufend Musik nach den Regeln der Dodekaphonie
- **Vier Reihenformen**: Original, Krebs (Retrograde), Umkehrung (Inversion), Krebsumkehrung
- **Reverb-Effekt**: Einstellbarer Hall mit Convolver-basierter Impulsantwort
- **Web Audio API**: Echtzeit-Klangsynthese im Browser
- **Audio-Visualisierung**: Wellenform und Frequenzspektrum in Echtzeit
- **Zwölftonmatrix**: Vollständige 12x12-Matrix aller möglichen Transpositionen
## Die Zwölftontechnik
Die Dodekaphonie wurde von Arnold Schönberg um 1921 entwickelt:
1. Alle 12 chromatischen Töne werden gleichberechtigt verwendet
2. Kein Ton darf wiederholt werden, bevor alle anderen gespielt wurden
3. Die Grundreihe erscheint in vier Formen:
- **Original (O)**: Die Grundreihe
- **Krebs (R)**: Rückwärts gespielt
- **Umkehrung (I)**: Intervalle gespiegelt
- **Krebsumkehrung (RI)**: Kombination aus Krebs und Umkehrung
4. Jede Form kann auf alle 12 Stufen transponiert werden (48 mögliche Reihen)
## Installation
1. PHP-Server starten (PHP 7.4+ erforderlich):
```bash
cd twelve-tone-synthesizer
php -S localhost:8000
```
2. Browser öffnen: `http://localhost:8000`
## Bedienung
- **Starten**: Startet die automatische Wiedergabe der Zwölftonreihe
- **Stoppen**: Beendet die Wiedergabe
- **Neue Reihe**: Generiert eine zufällige neue Zwölftonreihe
### Klangparameter
- **Tempo (BPM)**: Geschwindigkeit der Notenwiedergabe (40-300)
- **Oktave**: Tonhöhenbereich (2-6)
- **Reverb**: Hallanteil (0-100%)
- **Attack**: Einschwingzeit der Töne
- **Release**: Ausklingzeit der Töne
- **Wellenform**: Sinus, Dreieck, Rechteck, Sägezahn
## Technologie
- **PHP**: Backend für Zwölftonreihen-Generierung und Matrix-Berechnung
- **JavaScript**: Web Audio API für Klangsynthese
- **ConvolverNode**: Realistische Reverb-Simulation
- **Canvas API**: Audio-Visualisierung
## Lizenz
MIT License
+939
View File
@@ -0,0 +1,939 @@
<?php
/**
* Zwölfton-Synthesizer (Dodekaphonie)
* Nach der Lehre von Arnold Schönberg
*
* Regeln der Zwölftontechnik:
* 1. Alle 12 chromatischen Töne müssen verwendet werden
* 2. Kein Ton darf wiederholt werden, bevor alle anderen gespielt wurden
* 3. Die Reihe kann transformiert werden: Original, Krebs, Umkehrung, Krebsumkehrung
* 4. Transposition auf alle 12 Stufen ist erlaubt
*/
class TwelveToneGenerator {
private array $noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
private array $originalRow;
public function __construct() {
$this->originalRow = $this->generateRandomRow();
}
/**
* Generiert eine zufällige Zwölftonreihe
*/
public function generateRandomRow(): array {
$row = range(0, 11);
shuffle($row);
return $row;
}
/**
* Gibt die Grundreihe zurück
*/
public function getOriginalRow(): array {
return $this->originalRow;
}
/**
* Krebs (Retrograde) - Reihe rückwärts
*/
public function getRetrograde(): array {
return array_reverse($this->originalRow);
}
/**
* Umkehrung (Inversion) - Intervalle gespiegelt
*/
public function getInversion(): array {
$inversion = [];
$firstNote = $this->originalRow[0];
foreach ($this->originalRow as $note) {
$interval = $note - $firstNote;
$invertedNote = ($firstNote - $interval + 12) % 12;
$inversion[] = $invertedNote;
}
return $inversion;
}
/**
* Krebsumkehrung (Retrograde Inversion)
*/
public function getRetrogradeInversion(): array {
return array_reverse($this->getInversion());
}
/**
* Transponiert eine Reihe um n Halbtöne
*/
public function transpose(array $row, int $semitones): array {
return array_map(function($note) use ($semitones) {
return ($note + $semitones) % 12;
}, $row);
}
/**
* Konvertiert Notennummern zu Notennamen
*/
public function toNoteNames(array $row): array {
return array_map(function($note) {
return $this->noteNames[$note];
}, $row);
}
/**
* Generiert die komplette Zwölftonmatrix (12x12)
*/
public function generateMatrix(): array {
$matrix = [];
$inversion = $this->getInversion();
for ($i = 0; $i < 12; $i++) {
$transposition = $inversion[$i];
$matrix[$i] = $this->transpose($this->originalRow, $transposition);
}
return $matrix;
}
/**
* Gibt alle Daten als JSON zurück
*/
public function toJSON(): string {
return json_encode([
'original' => $this->originalRow,
'retrograde' => $this->getRetrograde(),
'inversion' => $this->getInversion(),
'retrogradeInversion' => $this->getRetrogradeInversion(),
'noteNames' => $this->toNoteNames($this->originalRow),
'matrix' => $this->generateMatrix()
]);
}
}
// API-Endpoint für neue Reihe
if (isset($_GET['action']) && $_GET['action'] === 'generate') {
header('Content-Type: application/json');
$generator = new TwelveToneGenerator();
echo $generator->toJSON();
exit;
}
$generator = new TwelveToneGenerator();
$initialData = $generator->toJSON();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zwölfton-Synthesizer | Dodekaphonie nach Schönberg</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
color: #e0e0e0;
overflow-x: hidden;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 30px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 30px;
}
h1 {
font-size: 2.5em;
background: linear-gradient(90deg, #00d4ff, #7b2cbf, #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 10px;
}
.subtitle {
color: #888;
font-size: 1.1em;
}
.controls {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 30px;
flex-wrap: wrap;
}
button {
padding: 15px 30px;
font-size: 1.1em;
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.btn-primary {
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: #fff;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 212, 255, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, #7b2cbf, #5a189a);
color: #fff;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(123, 44, 191, 0.3);
}
.btn-danger {
background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
color: #fff;
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(255, 107, 107, 0.3);
}
.panel {
background: rgba(255,255,255,0.05);
border-radius: 20px;
padding: 25px;
margin-bottom: 25px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.1);
}
.panel h2 {
margin-bottom: 20px;
color: #00d4ff;
font-size: 1.3em;
}
.row-display {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.note-box {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.1);
border: 2px solid rgba(0, 212, 255, 0.3);
border-radius: 10px;
font-weight: bold;
font-size: 1.2em;
transition: all 0.3s ease;
}
.note-box.active {
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
border-color: #fff;
transform: scale(1.2);
box-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
}
.note-box.played {
background: rgba(123, 44, 191, 0.3);
border-color: rgba(123, 44, 191, 0.5);
}
.sliders {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.slider-group label {
display: flex;
justify-content: space-between;
color: #aaa;
}
input[type="range"] {
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(255,255,255,0.1);
outline: none;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
cursor: pointer;
}
.visualizer-container {
height: 200px;
background: rgba(0,0,0,0.3);
border-radius: 15px;
overflow: hidden;
position: relative;
}
#visualizer {
width: 100%;
height: 100%;
}
.transformation-select {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 20px;
}
.transform-btn {
padding: 10px 20px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 25px;
color: #e0e0e0;
cursor: pointer;
transition: all 0.3s ease;
}
.transform-btn.active {
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
border-color: transparent;
}
.transform-btn:hover {
background: rgba(0, 212, 255, 0.3);
}
.status {
text-align: center;
padding: 15px;
background: rgba(0,0,0,0.2);
border-radius: 10px;
margin-top: 20px;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 10px;
background: #666;
}
.status-indicator.playing {
background: #00ff88;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.matrix-container {
overflow-x: auto;
}
.matrix {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 3px;
min-width: 500px;
}
.matrix-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.1);
border-radius: 5px;
font-size: 0.9em;
font-weight: 500;
}
.info-text {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 10px;
margin-top: 15px;
font-size: 0.9em;
color: #aaa;
line-height: 1.6;
}
footer {
text-align: center;
padding: 30px;
color: #666;
border-top: 1px solid rgba(255,255,255,0.1);
margin-top: 30px;
}
@media (max-width: 768px) {
h1 { font-size: 1.8em; }
.note-box { width: 45px; height: 45px; font-size: 1em; }
.controls { flex-direction: column; align-items: center; }
button { width: 100%; max-width: 300px; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Zwölfton-Synthesizer</h1>
<p class="subtitle">Dodekaphonie nach Arnold Schönberg</p>
</header>
<div class="controls">
<button id="startBtn" class="btn-primary"> Starten</button>
<button id="stopBtn" class="btn-danger"> Stoppen</button>
<button id="newRowBtn" class="btn-secondary">🎲 Neue Reihe</button>
</div>
<div class="panel">
<h2>Aktuelle Zwölftonreihe</h2>
<div class="transformation-select">
<button class="transform-btn active" data-transform="original">Original (O)</button>
<button class="transform-btn" data-transform="retrograde">Krebs (R)</button>
<button class="transform-btn" data-transform="inversion">Umkehrung (I)</button>
<button class="transform-btn" data-transform="retrogradeInversion">Krebsumkehrung (RI)</button>
</div>
<div id="rowDisplay" class="row-display"></div>
</div>
<div class="panel">
<h2>Klangparameter</h2>
<div class="sliders">
<div class="slider-group">
<label>Tempo (BPM): <span id="tempoValue">120</span></label>
<input type="range" id="tempo" min="40" max="300" value="120">
</div>
<div class="slider-group">
<label>Oktave: <span id="octaveValue">4</span></label>
<input type="range" id="octave" min="2" max="6" value="4">
</div>
<div class="slider-group">
<label>Reverb: <span id="reverbValue">50</span>%</label>
<input type="range" id="reverb" min="0" max="100" value="50">
</div>
<div class="slider-group">
<label>Attack: <span id="attackValue">0.05</span>s</label>
<input type="range" id="attack" min="1" max="500" value="50">
</div>
<div class="slider-group">
<label>Release: <span id="releaseValue">0.3</span>s</label>
<input type="range" id="release" min="50" max="2000" value="300">
</div>
<div class="slider-group">
<label>Wellenform:</label>
<select id="waveform" style="padding: 8px; border-radius: 5px; background: rgba(255,255,255,0.1); color: #e0e0e0; border: 1px solid rgba(255,255,255,0.2);">
<option value="sine">Sinus</option>
<option value="triangle">Dreieck</option>
<option value="square">Rechteck</option>
<option value="sawtooth" selected>Sägezahn</option>
</select>
</div>
</div>
</div>
<div class="panel">
<h2>Audio-Visualisierung</h2>
<div class="visualizer-container">
<canvas id="visualizer"></canvas>
</div>
<div class="status">
<span id="statusIndicator" class="status-indicator"></span>
<span id="statusText">Bereit zum Starten</span>
</div>
</div>
<div class="panel">
<h2>Zwölftonmatrix</h2>
<div class="matrix-container">
<div id="matrix" class="matrix"></div>
</div>
<div class="info-text">
<strong>Die Zwölftontechnik (Dodekaphonie):</strong><br>
Entwickelt von Arnold Schönberg um 1921. Alle 12 Halbtöne der chromatischen Tonleiter
werden gleichberechtigt verwendet. Die Grundreihe kann in vier Formen erscheinen:
<strong>Original (O)</strong> - die Grundreihe,
<strong>Krebs (R)</strong> - rückwärts gespielt,
<strong>Umkehrung (I)</strong> - Intervalle gespiegelt,
<strong>Krebsumkehrung (RI)</strong> - Kombination aus Krebs und Umkehrung.
Jede Form kann auf alle 12 Stufen transponiert werden (48 mögliche Reihen).
</div>
</div>
<footer>
<p>Zwölfton-Synthesizer &copy; 2024 | Basierend auf der Dodekaphonie von Arnold Schönberg</p>
</footer>
</div>
<script>
// Initiale Daten vom PHP-Backend
let rowData = <?= $initialData ?>;
// Audio-Kontext und Knoten
let audioContext = null;
let masterGain = null;
let reverbGain = null;
let dryGain = null;
let convolver = null;
let analyser = null;
let isPlaying = false;
let currentNoteIndex = 0;
let playInterval = null;
let currentTransform = 'original';
// Frequenzen für alle Noten (A4 = 440Hz)
const noteFrequencies = {
0: 261.63, // C
1: 277.18, // C#
2: 293.66, // D
3: 311.13, // D#
4: 329.63, // E
5: 349.23, // F
6: 369.99, // F#
7: 392.00, // G
8: 415.30, // G#
9: 440.00, // A
10: 466.16, // A#
11: 493.88 // B
};
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
/**
* Initialisiert den Audio-Kontext
*/
async function initAudio() {
if (audioContext) return;
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Master Gain
masterGain = audioContext.createGain();
masterGain.gain.value = 0.5;
masterGain.connect(audioContext.destination);
// Analyser für Visualisierung
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.connect(masterGain);
// Dry/Wet Gain für Reverb
dryGain = audioContext.createGain();
reverbGain = audioContext.createGain();
dryGain.connect(analyser);
reverbGain.connect(analyser);
// Convolver für Reverb
convolver = audioContext.createConvolver();
convolver.connect(reverbGain);
// Generiere Impulsantwort für Reverb
await createReverbImpulse();
updateReverbMix();
startVisualization();
}
/**
* Erstellt eine synthetische Impulsantwort für den Reverb
*/
async function createReverbImpulse() {
const sampleRate = audioContext.sampleRate;
const length = sampleRate * 3; // 3 Sekunden Reverb
const impulse = audioContext.createBuffer(2, length, sampleRate);
for (let channel = 0; channel < 2; channel++) {
const channelData = impulse.getChannelData(channel);
for (let i = 0; i < length; i++) {
// Exponentieller Decay mit etwas Rauschen
const decay = Math.pow(1 - i / length, 2);
channelData[i] = (Math.random() * 2 - 1) * decay;
}
}
convolver.buffer = impulse;
}
/**
* Aktualisiert das Dry/Wet-Verhältnis des Reverbs
*/
function updateReverbMix() {
const reverbAmount = document.getElementById('reverb').value / 100;
dryGain.gain.value = 1 - reverbAmount * 0.5;
reverbGain.gain.value = reverbAmount;
}
/**
* Spielt eine Note
*/
function playNote(noteNumber) {
if (!audioContext) return;
const octave = parseInt(document.getElementById('octave').value);
const frequency = noteFrequencies[noteNumber] * Math.pow(2, octave - 4);
const waveform = document.getElementById('waveform').value;
const attack = document.getElementById('attack').value / 1000;
const release = document.getElementById('release').value / 1000;
// Oszillator
const osc = audioContext.createOscillator();
osc.type = waveform;
osc.frequency.value = frequency;
// Gain für ADSR-Hüllkurve
const gainNode = audioContext.createGain();
gainNode.gain.value = 0;
// Verbindungen
osc.connect(gainNode);
gainNode.connect(dryGain);
gainNode.connect(convolver);
const now = audioContext.currentTime;
// Attack
gainNode.gain.linearRampToValueAtTime(0.7, now + attack);
// Release
gainNode.gain.linearRampToValueAtTime(0, now + attack + release);
osc.start(now);
osc.stop(now + attack + release + 0.1);
}
/**
* Startet die automatische Wiedergabe
*/
async function startPlaying() {
await initAudio();
if (isPlaying) return;
isPlaying = true;
currentNoteIndex = 0;
updateStatus(true);
playNextNote();
}
/**
* Stoppt die Wiedergabe
*/
function stopPlaying() {
isPlaying = false;
if (playInterval) {
clearTimeout(playInterval);
playInterval = null;
}
updateStatus(false);
resetNoteDisplay();
}
/**
* Spielt die nächste Note in der Reihe
*/
function playNextNote() {
if (!isPlaying) return;
const row = getCurrentRow();
const noteNumber = row[currentNoteIndex];
// Visuelle Hervorhebung
updateNoteDisplay(currentNoteIndex);
// Note spielen
playNote(noteNumber);
// Nächste Note vorbereiten
currentNoteIndex++;
if (currentNoteIndex >= 12) {
currentNoteIndex = 0;
// Zufällig Transformation wechseln (optional)
if (Math.random() > 0.7) {
const transforms = ['original', 'retrograde', 'inversion', 'retrogradeInversion'];
currentTransform = transforms[Math.floor(Math.random() * transforms.length)];
updateTransformButtons();
displayRow();
}
}
// Tempo berechnen
const bpm = parseInt(document.getElementById('tempo').value);
const interval = 60000 / bpm;
playInterval = setTimeout(playNextNote, interval);
}
/**
* Gibt die aktuelle Reihenform zurück
*/
function getCurrentRow() {
switch (currentTransform) {
case 'retrograde': return rowData.retrograde;
case 'inversion': return rowData.inversion;
case 'retrogradeInversion': return rowData.retrogradeInversion;
default: return rowData.original;
}
}
/**
* Aktualisiert die visuelle Darstellung der aktuellen Note
*/
function updateNoteDisplay(activeIndex) {
const boxes = document.querySelectorAll('.note-box');
boxes.forEach((box, index) => {
box.classList.remove('active');
if (index < activeIndex) {
box.classList.add('played');
} else {
box.classList.remove('played');
}
if (index === activeIndex) {
box.classList.add('active');
}
});
}
/**
* Setzt die Notenanzeige zurück
*/
function resetNoteDisplay() {
const boxes = document.querySelectorAll('.note-box');
boxes.forEach(box => {
box.classList.remove('active', 'played');
});
}
/**
* Aktualisiert den Status-Anzeiger
*/
function updateStatus(playing) {
const indicator = document.getElementById('statusIndicator');
const text = document.getElementById('statusText');
if (playing) {
indicator.classList.add('playing');
text.textContent = 'Spielt...';
} else {
indicator.classList.remove('playing');
text.textContent = 'Gestoppt';
}
}
/**
* Zeigt die Zwölftonreihe an
*/
function displayRow() {
const container = document.getElementById('rowDisplay');
const row = getCurrentRow();
container.innerHTML = row.map((note, index) => `
<div class="note-box" data-note="${note}" data-index="${index}">
${noteNames[note]}
</div>
`).join('');
}
/**
* Zeigt die Zwölftonmatrix an
*/
function displayMatrix() {
const container = document.getElementById('matrix');
const matrix = rowData.matrix;
container.innerHTML = matrix.flat().map(note => `
<div class="matrix-cell">${noteNames[note]}</div>
`).join('');
}
/**
* Aktualisiert die Transformations-Buttons
*/
function updateTransformButtons() {
document.querySelectorAll('.transform-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.transform === currentTransform);
});
}
/**
* Generiert eine neue Reihe vom Server
*/
async function generateNewRow() {
try {
const response = await fetch('?action=generate');
rowData = await response.json();
displayRow();
displayMatrix();
currentNoteIndex = 0;
resetNoteDisplay();
} catch (error) {
console.error('Fehler beim Generieren:', error);
}
}
/**
* Audio-Visualisierung
*/
function startVisualization() {
const canvas = document.getElementById('visualizer');
const ctx = canvas.getContext('2d');
function resize() {
canvas.width = canvas.offsetWidth * window.devicePixelRatio;
canvas.height = canvas.offsetHeight * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
resize();
window.addEventListener('resize', resize);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
requestAnimationFrame(draw);
analyser.getByteTimeDomainData(dataArray);
const width = canvas.offsetWidth;
const height = canvas.offsetHeight;
// Hintergrund mit Fade-Effekt
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, width, height);
// Wellenform zeichnen
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(0, 212, 255, 0.8)';
ctx.beginPath();
const sliceWidth = width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * height / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(width, height / 2);
ctx.stroke();
// Frequenzspektrum
analyser.getByteFrequencyData(dataArray);
const barWidth = (width / 64) * 1.5;
let barX = 0;
for (let i = 0; i < 64; i++) {
const barHeight = (dataArray[i] / 255) * height * 0.7;
const gradient = ctx.createLinearGradient(0, height - barHeight, 0, height);
gradient.addColorStop(0, 'rgba(123, 44, 191, 0.8)');
gradient.addColorStop(1, 'rgba(0, 212, 255, 0.8)');
ctx.fillStyle = gradient;
ctx.fillRect(barX, height - barHeight, barWidth - 2, barHeight);
barX += barWidth;
}
}
draw();
}
// Event Listeners
document.getElementById('startBtn').addEventListener('click', startPlaying);
document.getElementById('stopBtn').addEventListener('click', stopPlaying);
document.getElementById('newRowBtn').addEventListener('click', generateNewRow);
document.querySelectorAll('.transform-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentTransform = btn.dataset.transform;
updateTransformButtons();
displayRow();
currentNoteIndex = 0;
resetNoteDisplay();
});
});
// Slider Updates
document.getElementById('tempo').addEventListener('input', e => {
document.getElementById('tempoValue').textContent = e.target.value;
});
document.getElementById('octave').addEventListener('input', e => {
document.getElementById('octaveValue').textContent = e.target.value;
});
document.getElementById('reverb').addEventListener('input', e => {
document.getElementById('reverbValue').textContent = e.target.value;
if (audioContext) updateReverbMix();
});
document.getElementById('attack').addEventListener('input', e => {
document.getElementById('attackValue').textContent = (e.target.value / 1000).toFixed(2);
});
document.getElementById('release').addEventListener('input', e => {
document.getElementById('releaseValue').textContent = (e.target.value / 1000).toFixed(2);
});
// Initialisierung
displayRow();
displayMatrix();
</script>
</body>
</html>