opencode-smart-voice-notify 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+
6
+ /**
7
+ * Per-Project Sound Module
8
+ *
9
+ * Provides logic for assigning unique sounds to different projects.
10
+ * Hashes project directory + seed to pick a consistent sound from assets.
11
+ */
12
+
13
+ const projectSoundCache = new Map();
14
+
15
+ /**
16
+ * Internal debug logger
17
+ * @param {string} message
18
+ * @param {object} config
19
+ */
20
+ const debugLog = (message, config) => {
21
+ if (!config || !config.debugLog) return;
22
+
23
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
24
+ const logsDir = path.join(configDir, 'logs');
25
+ const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
26
+
27
+ try {
28
+ if (!fs.existsSync(logsDir)) {
29
+ fs.mkdirSync(logsDir, { recursive: true });
30
+ }
31
+ const timestamp = new Date().toISOString();
32
+ fs.appendFileSync(logFile, `[${timestamp}] [per-project-sound] ${message}\n`);
33
+ } catch (e) {
34
+ // Silently fail - logging is optional
35
+ }
36
+ };
37
+
38
+ /**
39
+ * Get a unique sound for a project by hashing its path.
40
+ * @param {object} project - The project object (should contain directory)
41
+ * @param {object} config - Plugin configuration
42
+ * @returns {string | null} Relative path to the project-specific sound, or null if disabled/unavailable
43
+ */
44
+ export const getProjectSound = (project, config) => {
45
+ if (!config || !config.perProjectSounds || !project?.directory) {
46
+ return null;
47
+ }
48
+
49
+ const projectPath = project.directory;
50
+
51
+ // Use cache to ensure consistency within session
52
+ if (projectSoundCache.has(projectPath)) {
53
+ const cachedSound = projectSoundCache.get(projectPath);
54
+ debugLog(`Returning cached sound for project: ${projectPath} -> ${cachedSound}`, config);
55
+ return cachedSound;
56
+ }
57
+
58
+ try {
59
+ // Hash the path + seed
60
+ const seed = config.projectSoundSeed || 0;
61
+ // We use MD5 because it's fast and sufficient for this purpose
62
+ const hash = crypto.createHash('md5').update(projectPath + seed).digest('hex');
63
+
64
+ // Map hash to 1-6 (opencode-notificator pattern)
65
+ // Using first 8 chars of hash for a stable number
66
+ const soundIndex = (parseInt(hash.substring(0, 8), 16) % 6) + 1;
67
+ const soundFile = `assets/ding${soundIndex}.mp3`;
68
+
69
+ debugLog(`Assigned new sound for project: ${projectPath} (seed: ${seed}) -> ${soundFile}`, config);
70
+
71
+ // Cache and return
72
+ projectSoundCache.set(projectPath, soundFile);
73
+ return soundFile;
74
+ } catch (e) {
75
+ debugLog(`Error assigning project sound: ${e.message}`, config);
76
+ return null;
77
+ }
78
+ };
79
+
80
+ /**
81
+ * Clear the project sound cache (used for testing)
82
+ */
83
+ export const clearProjectSoundCache = () => {
84
+ projectSoundCache.clear();
85
+ };
86
+
87
+ export default {
88
+ getProjectSound,
89
+ clearProjectSoundCache
90
+ };
@@ -0,0 +1,129 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ /**
6
+ * Sound Theme Module
7
+ *
8
+ * Provides functionality for themed sound packs.
9
+ * Supports directory structure with idle/, permission/, error/, and question/ subdirectories.
10
+ */
11
+
12
+ const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
13
+
14
+ /**
15
+ * Internal debug logger
16
+ * @param {string} message
17
+ * @param {object} config
18
+ */
19
+ const debugLog = (message, config) => {
20
+ if (!config || !config.debugLog) return;
21
+
22
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
23
+ const logsDir = path.join(configDir, 'logs');
24
+ const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
25
+
26
+ try {
27
+ if (!fs.existsSync(logsDir)) {
28
+ fs.mkdirSync(logsDir, { recursive: true });
29
+ }
30
+ const timestamp = new Date().toISOString();
31
+ fs.appendFileSync(logFile, `[${timestamp}] [sound-theme] ${message}\n`);
32
+ } catch (e) {
33
+ // Silently fail - logging is optional
34
+ }
35
+ };
36
+
37
+ /**
38
+ * List all audio files in a theme subdirectory
39
+ * @param {string} themeDir - Root theme directory
40
+ * @param {string} eventType - Subdirectory name (idle, permission, error, question)
41
+ * @returns {string[]} Absolute paths to audio files
42
+ */
43
+ export const listSoundsInTheme = (themeDir, eventType) => {
44
+ if (!themeDir) return [];
45
+
46
+ const subDir = path.join(themeDir, eventType);
47
+ if (!fs.existsSync(subDir) || !fs.statSync(subDir).isDirectory()) {
48
+ return [];
49
+ }
50
+
51
+ try {
52
+ return fs.readdirSync(subDir)
53
+ .filter(file => AUDIO_EXTENSIONS.includes(path.extname(file).toLowerCase()))
54
+ .sort() // Sort alphabetically for consistent cross-platform behavior
55
+ .map(file => path.join(subDir, file))
56
+ .filter(filePath => fs.statSync(filePath).isFile());
57
+ } catch (error) {
58
+ return [];
59
+ }
60
+ };
61
+
62
+ /**
63
+ * Pick a sound for the given event type from the theme directory
64
+ * @param {string} eventType - Type of event (idle, permission, error, question)
65
+ * @param {object} config - Plugin configuration
66
+ * @returns {string|null} Path to the selected sound, or null if theme not available
67
+ */
68
+ export const pickThemeSound = (eventType, config) => {
69
+ if (!config.soundThemeDir) return null;
70
+
71
+ // Resolve absolute path if relative
72
+ let themeDir = config.soundThemeDir;
73
+ if (!path.isAbsolute(themeDir)) {
74
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
75
+ themeDir = path.join(configDir, themeDir);
76
+ }
77
+
78
+ if (!fs.existsSync(themeDir)) {
79
+ debugLog(`Theme directory not found: ${themeDir}`, config);
80
+ return null;
81
+ }
82
+
83
+ const sounds = listSoundsInTheme(themeDir, eventType);
84
+ if (sounds.length === 0) {
85
+ debugLog(`No sounds found for event type '${eventType}' in theme: ${themeDir}`, config);
86
+ return null;
87
+ }
88
+
89
+ let selected;
90
+ if (config.randomizeSoundFromTheme) {
91
+ const randomIndex = Math.floor(Math.random() * sounds.length);
92
+ selected = sounds[randomIndex];
93
+ debugLog(`Randomly selected sound for '${eventType}': ${selected} (from ${sounds.length} files)`, config);
94
+ } else {
95
+ selected = sounds[0];
96
+ debugLog(`Selected first sound for '${eventType}': ${selected}`, config);
97
+ }
98
+
99
+ return selected;
100
+ };
101
+
102
+ /**
103
+ * Pick a random sound from a directory
104
+ * @param {string} dirPath - Directory path
105
+ * @returns {string|null} Path to a random audio file
106
+ */
107
+ export const pickRandomSound = (dirPath) => {
108
+ if (!dirPath || !fs.existsSync(dirPath)) return null;
109
+
110
+ try {
111
+ const files = fs.readdirSync(dirPath)
112
+ .filter(file => AUDIO_EXTENSIONS.includes(path.extname(file).toLowerCase()))
113
+ .map(file => path.join(dirPath, file))
114
+ .filter(filePath => fs.statSync(filePath).isFile());
115
+
116
+ if (files.length === 0) return null;
117
+
118
+ const randomIndex = Math.floor(Math.random() * files.length);
119
+ return files[randomIndex];
120
+ } catch (error) {
121
+ return null;
122
+ }
123
+ };
124
+
125
+ export default {
126
+ listSoundsInTheme,
127
+ pickThemeSound,
128
+ pickRandomSound
129
+ };